From c0934cc804fb5c23f0ff38e463cdfb00784da159 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Fri, 31 Jul 2026 10:58:22 +0800 Subject: [PATCH] fix: harden options and publication contracts --- .github/ISSUE_TEMPLATE/bug_report.yml | 3 +- AGENTS.md | 50 ++-- ARCHITECTURE.md | 35 ++- COMPATIBILITY.md | 30 ++- README.md | 66 +++-- RELEASING.md | 42 ++- ROADMAP.md | 83 ++++-- eslint.config.js | 4 +- fixtures/openai-host/consumer.cts | 113 ++++++++ fixtures/openai-host/consumer.mts | 113 ++++++++ .../openai-host/exact-optional-consumer.cts | 16 ++ .../openai-host/exact-optional-consumer.mts | 16 ++ fixtures/openai-host/package.json | 2 +- fixtures/openai-host/tsconfig.json | 7 +- fixtures/openai-host/tsconfig.nonexact.json | 7 + scripts/release-validation.mjs | 250 +++++++++++++++++- scripts/test-package.mjs | 17 +- src/client.ts | 12 +- tests/config.test.ts | 72 +++-- tests/options-types.ts | 124 ++++++++- tests/publication-neutral-docs.test.mjs | 94 +++++++ tests/release-validation.test.mjs | 50 +++- 22 files changed, 1035 insertions(+), 171 deletions(-) create mode 100644 fixtures/openai-host/exact-optional-consumer.cts create mode 100644 fixtures/openai-host/exact-optional-consumer.mts create mode 100644 fixtures/openai-host/tsconfig.nonexact.json create mode 100644 tests/publication-neutral-docs.test.mjs diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 88f7273..6f089a8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -12,7 +12,8 @@ body: id: version attributes: label: CometAPI SDK version - placeholder: 0.1.0-alpha.1 + description: Run `npm list cometapi` and report the installed package version. + placeholder: Paste the installed package version validations: required: true - type: input diff --git a/AGENTS.md b/AGENTS.md index f126c3c..024d8e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,14 +51,15 @@ repository. merely to complete lifecycle cleanup. Fail closed and report the exact state whenever a required cleanliness, fetch, or fast-forward condition is not met. -## Current Milestone: Stable 0.1.1 and Repository Foundation Complete +## Current Milestone: Stable 0.1.x Maintenance -Private Remote Validation, Public Preview, Registry Alpha, stable `0.1.0`, and -the `0.1.1` maintenance patch are complete. The canonical repository is public, -`0.1.1` is available from npm's `latest` channel, and `0.1.0-alpha.3` remains -available from `next`; the published lines have verified provenance and -public-install evidence. No later milestone is active. Do not begin the 0.2 -provider adapters without an explicit maintainer request. +Private Remote Validation, Public Preview, Registry Alpha, and Repository +foundation are complete. The canonical repository is public and remains in +stable 0.1.x maintenance: stable packages use npm's `latest` channel and +Registry Alpha artifacts use `next`. Do not pin an exact current package or +Release version in durable repository guidance. Query npm and GitHub when exact +state matters. No later milestone is active; do not begin the 0.2 provider +adapters without an explicit maintainer request. The accepted identity is: @@ -78,21 +79,23 @@ The unscoped `cometapi` package is the primary Node SDK. `@cometapi` is the standard scope for future official scoped packages; do not introduce new official packages under `@cometapi-dev`. -Stable `0.1.1` corrected the public options boundary without expanding the 0.1 -resource surface. Release Please created the reviewed patch PR, immutable tag, -and GitHub Release. Publication required a disclosed one-time main-context -recovery because the immutable tag predated the repaired tag handoff. The -recovery published only the exact previously verified artifact through npm -OIDC, then the repository restored its variables and tag-only Environment -policy. The current workflow contains no publication-recovery input, fixed -recovery run or artifact ID, prior-package-artifact or live-evidence reuse, or -branch-context publication path. +Stable `0.1.1` established the runtime options boundary and repaired the release +path without expanding the 0.1 resource surface. Release Please created the +reviewed patch PR, immutable tag, and GitHub Release. Publication required a +disclosed one-time main-context recovery because the immutable tag predated the +repaired tag handoff. The recovery published only the exact previously verified +artifact through npm OIDC, then the repository restored its variables and +tag-only Environment policy. The current workflow contains no +publication-recovery input, fixed recovery run or artifact ID, +prior-package-artifact or live-evidence reuse, or branch-context publication +path. Release Please remains disabled between explicitly authorized release operations. Permanent stable patches follow only the tag-bound path in -`RELEASING.md`. The `0.1.1` recovery provenance is historical evidence, not -proof that the current permanent tag path has completed a registry publication; -the next explicitly authorized stable patch is its first end-to-end execution. +`RELEASING.md`. The recovery provenance for stable `0.1.1` is historical +evidence and does not prove an execution of the permanent path. The next +explicitly authorized stable patch is that path's first end-to-end registry +execution. ## Product Contract @@ -125,6 +128,11 @@ message translation are not 0.1 features. Do not hand-write replacements for those layers. - Use only documented public OpenAI constructor and client options. Do not depend on private upstream attributes or methods. +- Declare CometAPI-owned `provider`, `workloadIdentity`, and + `dangerouslyAllowBrowser` fields as optional `never` properties on + `CometAPIOptions`. The constructor and `withOptions` must reject every + non-`undefined` value at runtime as well as through TypeScript variables, + spreads, and constrained generics. - Preserve official OpenAI request, response, stream, and exception types for the supported 0.1 operations. - Keep CometAPI-specific resources in `src/resources/` and their types in @@ -243,6 +251,10 @@ repository root. - Inspect `npm pack` output, install the exact tarball in clean ESM, CommonJS, and compatible-OpenAI host fixtures, and verify version agreement before publication. +- Treat `package.json` as the sole candidate-version authority. A normal stable + Release Please PR changes exactly `.release-please-manifest.json`, + `CHANGELOG.md`, `package-lock.json`, and `package.json`; all four version + records must agree, while durable documentation remains publication-neutral. - Keep development checks compatible with unresolved maintainer input, but make the publish workflow fail closed until maintainers have supplied the copyright holder, canonical security and support contacts, repository diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7b4112c..a610be3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -29,14 +29,16 @@ only for CometAPI defaults and public branding: 3. The default base URL is `https://api.cometapi.com/v1`. 4. Other documented and supported OpenAI client options pass through unchanged. -The public `CometAPIOptions` type excludes the upstream `provider`, -`workloadIdentity`, and `dangerouslyAllowBrowser` fields in addition to the -CometAPI-owned `apiKey` and `baseURL` fields. Provider and workload-identity -routing conflict with the API key and base URL that this client injects. -Browser-side long-lived key use is outside the 0.1 security boundary. These -fields never represented valid CometAPI behavior, so their removal from the -public type is a 0.1.1 contract correction rather than a supported feature -removal. +The public `CometAPIOptions` type omits the upstream definitions of `provider`, +`workloadIdentity`, and `dangerouslyAllowBrowser`, then redeclares those names as +`provider?: never`, `workloadIdentity?: never`, and +`dangerouslyAllowBrowser?: never`. Provider and workload-identity routing +conflict with the API key and base URL that this client injects. Browser-side +long-lived key use is outside the 0.1 security boundary. The optional-`never` +contract makes variables, spreads, and constrained generics structurally +incompatible when they carry a non-`undefined` reserved value. These fields +never represented valid CometAPI behavior, so tightening the declaration within +stable 0.1.x is not a supported feature removal. The inherited `withOptions` path is constrained to the same `CometAPIOptions` contract. Both the constructor and `withOptions` validate @@ -99,15 +101,22 @@ The package manifest declares only intended runtime files. Generated build artifacts and dependency directories are not committed. A successful source-tree import is not package evidence; verification must use the packed artifact. -`package.json` is the source of the candidate version. Local and release checks -derive the version from it and require agreement with the package-lock root, -the Release Please manifest or the documented one-time bootstrap, the single -candidate changelog heading, and packed metadata. Remote publication adds exact -tag and GitHub release agreement. +`package.json` is the sole source of the candidate version. Local and release +checks derive the version from it and require agreement with the package-lock +root, the Release Please manifest or the documented one-time bootstrap, the +single candidate changelog heading, and packed metadata. A normal stable +Release Please PR changes exactly `.release-please-manifest.json`, +`CHANGELOG.md`, `package-lock.json`, and `package.json`; version-specific status +does not belong in its durable documentation. Remote publication adds exact tag +and GitHub Release agreement. The publish workflow is the sole source of npm dist-tag selection: prereleases use `next`, stable versions use `latest`. The package manifest must not carry a static dist-tag because that would make stable and prerelease policy diverge. +Exact registry and Release state must be queried from npm and GitHub rather than +inferred from repository prose. The unversioned package page is +, and GitHub release state is available +from . Trusted Publishing is the only executable authentication path. The protected- environment token bootstrap used for `0.1.0-alpha.1` is historical evidence; current workflows contain no token publication path and reject registry-token diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 65ed0b6..c86bcc8 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -3,9 +3,12 @@ Compatibility document version: 0.1 Package line: `0.1.x` -Stable release: `0.1.1`; the immutable Release, bounded live smoke, npm OIDC -publication, and separate public-registry verification completed on 2026-07-30. -Registry Alpha `0.1.0-alpha.3` remains available from npm's `next` channel. +Maintenance status: stable `0.1.x`. Stable packages use npm's `latest` channel, +and Registry Alpha artifacts use `next`. Exact package, dist-tag, and GitHub +Release state is intentionally not pinned here; query + and +. Dated evidence for +each completed release remains below. This matrix defines the contract-tested 0.1 compatibility surface. Inheritance from the official OpenAI client does not by itself establish CometAPI support. @@ -42,24 +45,27 @@ error contract. The 0.1 client keeps supported OpenAI transport and observability options, while reserving CometAPI routing, authentication, and the browser security boundary. -The 0.1.0 declarations mistakenly admitted the three reserved fields even -though they could not produce valid, supported CometAPI behavior; 0.1.1 corrects -that contract: +Earlier stable declarations omitted the three reserved fields, which rejected +fresh object literals but still admitted structurally typed variables. Those +fields never produced valid, supported CometAPI behavior. Stable maintenance +therefore makes the prohibition structural: | Option group | Contract | | -------------------------------------------------------------------------------------------- | -------------------------------------------- | | `timeout`, `maxRetries`, `fetch`, `fetchOptions`, `defaultHeaders`, `defaultQuery`, `logger` | Supported constructor pass-through | | `organization`, `project`, `webhookSecret`, `adminAPIKey` | Supported constructor pass-through | | Per-request options | Supported for the contract-tested operations | -| `provider`, `workloadIdentity`, `dangerouslyAllowBrowser` | Rejected by declarations and at runtime | +| `provider?: never`, `workloadIdentity?: never`, `dangerouslyAllowBrowser?: never` | Rejected by declarations and at runtime | `provider` and `workloadIdentity` would conflict with the CometAPI API key and base URL injected by the SDK. `dangerouslyAllowBrowser` would cross the 0.1 -long-lived-key boundary. The constructor and `withOptions` enforce the same -rule. Runtime rejections use the official OpenAI `OpenAIError`, identify only -the forbidden field, and do not include its value. Compile-time negative tests -are executed by TypeScript against source and packed ESM/CommonJS declarations; -runtime tests cover plain JavaScript and type-cast bypasses. +long-lived-key boundary. Redeclaring all three fields as optional `never` makes +non-`undefined` values incompatible through object literals, inferred +variables, spreads, and constrained generics. The constructor and `withOptions` +enforce the same rule at runtime. Runtime rejections use the official OpenAI +`OpenAIError`, identify only the forbidden field, and do not include its value. +Compile-time negative tests run against source and packed ESM/CommonJS +declarations; runtime tests cover plain JavaScript and type-cast bypasses. ## Inherited but unsupported in 0.1 diff --git a/README.md b/README.md index e8ca5e7..5cb0919 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ The official CometAPI entry point for the OpenAI-compatible API. The SDK keeps the official OpenAI JavaScript request, response, stream, and error types while defaulting the client to CometAPI. -> **Stable 0.1 release:** `0.1.1` is published on npm's default `latest` -> dist-tag. Its immutable GitHub Release, bounded live smoke, npm OIDC -> publication, provenance, signature, and separate public-registry installation -> verification are complete. The supported API remains limited to the -> contract-tested 0.1 surface documented here and in +> **Stable 0.1.x maintenance:** Stable packages install from npm's default +> `latest` dist-tag, while prerelease artifacts use `next`. Exact package, +> dist-tag, and GitHub Release state is intentionally not pinned in this README; +> query the registries when that state matters. The supported API remains +> limited to the contract-tested 0.1 surface documented here and in > [COMPATIBILITY.md](./COMPATIBILITY.md). ## Supported 0.1 surface @@ -46,6 +46,17 @@ Install the stable package from npm's default `latest` dist-tag: npm install cometapi ``` +The unversioned registry page is +. Query npm and GitHub instead of using +an exact version copied from repository prose: + +```bash +npm view cometapi version +npm view cometapi dist-tags --json +gh release view --repo cometapi-dev/cometapi-node \ + --json tagName,isDraft,isPrerelease,publishedAt,url +``` + The release workflow is the sole source of the npm dist-tag: prerelease versions publish to `next`, while stable versions publish to `latest`. The package manifest does not declare a static dist-tag. @@ -164,12 +175,13 @@ const response = await client.chat.completions.create( ); ``` -Starting with 0.1.1, the public type matches the runtime boundary that 0.1.0 -intended. The SDK owns CometAPI routing, authentication, and the Node-only -secret boundary. Consequently, `provider`, `workloadIdentity`, and -`dangerouslyAllowBrowser` are not `CometAPIOptions`. They are rejected both by -the TypeScript declarations and at runtime when plain JavaScript or a type cast -bypasses those declarations. The same restriction applies to inherited +The 0.1.x public type matches the enforced runtime boundary. The SDK owns +CometAPI routing, authentication, and the Node-only secret boundary. +`CometAPIOptions` therefore declares `provider?: never`, +`workloadIdentity?: never`, and `dangerouslyAllowBrowser?: never`. Non-`undefined` +values are rejected by TypeScript, including through inferred variables, +spreads, and constrained generics, and runtime validation protects plain +JavaScript and type-cast bypasses. The same restriction applies to inherited `withOptions` calls. A rejection is an official OpenAI `OpenAIError` and names only the forbidden field; it never includes the supplied value. @@ -229,24 +241,20 @@ parent. ## Project status -The repository has completed Public Preview, Registry Alpha, stable `0.1.0`, -the `0.1.1` maintenance patch, and Repository foundation. Blocking CI, -protected repository rules, security reporting, protected environments, and -the authorized bounded live smoke have passed. Stable `0.1.1` is available from -`latest`; Registry Alpha `0.1.0-alpha.3` remains available from `next`. A -separate public-registry check passed ESM, CommonJS, declarations, supported -mocked calls, the compatible-OpenAI host fixture with one effective OpenAI -installation, official error identity, integrity, signature, and provenance. - -The immutable `0.1.0-alpha.2` GitHub release remains as an unpublished failure -record because its guard stopped before invoking npm. Mocked responses, packed -artifacts, GitHub Actions, trusted live tests, and npm publication remain -separate evidence layers and must not be represented as another. Published npm -artifacts are immutable, so the `0.1.1` tarball retains its candidate-era -README; this post-release status first ships in a later package version. The -one-time `0.1.1` publication recovery is documented as historical evidence in -[RELEASING.md](./RELEASING.md); the current permanent release workflow is -immutable-tag-bound. No 0.2 provider adapter work is included. +The repository is in stable 0.1.x maintenance, and no 0.2 provider-adapter +milestone is active. Repository foundation, Public Preview, and Registry Alpha +are complete. Stable packages use `latest`; Registry Alpha artifacts use +`next`. Use the npm and GitHub queries in [Installation](#installation) for +exact current state. Release-specific CI, live-smoke, registry, integrity, +signature, provenance, and public-install evidence is retained in +[RELEASING.md](./RELEASING.md), not restated as mutable version status here. + +Mocked responses, packed artifacts, GitHub Actions, trusted live tests, and npm +publication remain separate evidence layers and must not be represented as one +another. Exact failed-release, immutable-artifact, and one-time recovery history +is retained in [RELEASING.md](./RELEASING.md) rather than reproduced in this +consumer README. The permanent release workflow is immutable-tag-bound and +publishes through npm OIDC. See: diff --git a/RELEASING.md b/RELEASING.md index 1fba0fd..5c7b05a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -84,7 +84,7 @@ evidence is complete only when `npm owner ls cometapi` lists the maintainer-confirmed `cometapi_dev` account; until then this remains a Registry Alpha prerequisite. -Public Preview, Registry Alpha, stable `0.1.0`, stable `0.1.1`, and Repository +Public Preview, Registry Alpha, stable 0.1.x maintenance, and Repository foundation are complete. Future topic pushes, pull requests, merges, immutable GitHub Releases, bounded live smoke, npm publication, and environment approvals require authorization from the @@ -184,7 +184,7 @@ that any workflow executed remotely. ## Version and dist-tag agreement -`package.json` is the source of the candidate version; validation must not +`package.json` is the sole candidate-version authority; validation must not hard-code a second version as an independent authority. Before release, local package checks derive that value and require: @@ -196,6 +196,19 @@ package checks derive that value and require: 4. The packed package metadata name, version, dependency range, and runtime contract to match the source manifest. +For normal stable maintenance, the generated Release Please PR changes exactly +these four files: + +- `.release-please-manifest.json` +- `CHANGELOG.md` +- `package-lock.json` +- `package.json` + +Those files carry the candidate-specific version agreement. README, status, +compatibility, and runbook prose must remain publication-neutral so the +generated PR does not require a manually predicted version or an additional +documentation mutation. + The release workflow then requires the Release Please manifest to equal the candidate, the one-time `release-as` bootstrap to be absent, the changelog heading to be uniquely dated, the immutable tag to be `v`, the GitHub @@ -207,6 +220,14 @@ to run again. The workflow is the sole source of npm dist-tag selection: a version containing a prerelease component publishes to `next`, and a stable version publishes to `latest`. `package.json` must not define a static `publishConfig.tag`. +Query exact public state when needed instead of copying it into durable prose: + +```bash +npm view cometapi version +npm view cometapi dist-tags --json +gh release view --repo cometapi-dev/cometapi-node \ + --json tagName,isDraft,isPrerelease,publishedAt,url +``` ## Releasable documentation and identity gate @@ -217,8 +238,9 @@ live smoke, or registry access unless all of these conditions hold: 1. `LICENSE` contains a maintainer-supplied copyright year and holder with no placeholder. -2. `README.md` removes every local, unpublished, and pending-owner status and - explicitly states ` is approved for npm publication`. +2. `README.md` contains the exact unpinned `npm install cometapi` command, links + to the unversioned npm package page, and contains no exact-version current, + approval, unpublished-candidate, or in-progress release claim. 3. `SECURITY.md` and `SUPPORT.md` remove stale candidate status and each contain a maintainer-supplied canonical email address or GitHub repository contact URL. 4. The candidate's dated `CHANGELOG.md` section contains no candidate-only @@ -483,9 +505,10 @@ layers: host already declaring `openai@6.47.0`. The host resolved one effective OpenAI installation and preserved `APIError instanceof` identity. Registry signatures and attestations were verified with `npm audit signatures`. -- The `next` dist-tag resolves to `0.1.0-alpha.3`. Stable publication moved - `latest` to `0.1.0`; the historical registry-created `latest` value on - `0.1.0-alpha.1` no longer remains. +- At the Registry Alpha closeout on 2026-07-27, npm had assigned + `0.1.0-alpha.3` to the prerelease channel. At the stable `0.1.0` closeout, npm + had assigned `0.1.0` to the stable channel and replaced the registry-created + assignment for `0.1.0-alpha.1`. ## Stable 0.1.x sequence @@ -670,8 +693,9 @@ layers: request budget. Its [npm job 90841672147](https://github.com/cometapi-dev/cometapi-node/actions/runs/30533520823/job/90841672147) used GitHub Actions OIDC and completed the public-registry gates. -- npm now resolves `latest=0.1.1` and `next=0.1.0-alpha.3`. The registry tarball - is byte-identical to the source artifact and has SHA-1 +- At the stable `0.1.1` closeout on 2026-07-30, npm had assigned `0.1.1` to the + stable channel and `0.1.0-alpha.3` to the prerelease channel. The registry + tarball was byte-identical to the source artifact and had SHA-1 `00edad522c9ffaf937facbe5a35ef211869551c1` and integrity `sha512-uYo573XD+ITsa8F4GbYLAlXMrj9SA1Qc4KBgvoRbnwwTvfX+Ye8QVo7xgSNOl9AoYpoglQCW8lEo9cvjwan+7Q==`. `npm audit signatures` passed. npm exposes both its publish attestation diff --git a/ROADMAP.md b/ROADMAP.md index bc74e34..c66a062 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # CometAPI TypeScript and Node.js SDK Roadmap -Status: Stable 0.1.1 and Repository foundation complete -Last updated: 2026-07-30 +Status: Stable 0.1.x maintenance and Repository foundation complete +Last updated: 2026-07-31 Repository contract: This roadmap is self-contained and is the public source of truth for this repository's release sequence. @@ -18,13 +18,15 @@ small, auditable package for supported Node.js LTS runtimes. Private Remote Validation and Public Preview are complete for the sanitized repository. The repository is public with blocking CI, protected repository and tag rules, Private Vulnerability Reporting, protected environments, and -authorized live-smoke evidence. The functional `0.1.0-alpha.1` prerelease is -available from npm. The immutable `0.1.0-alpha.2` GitHub release failed locally -before invoking npm because its publication guard rejected the fixed -`actions/setup-node` authentication placeholder. `0.1.0-alpha.3` completed the -OIDC, provenance, ownership, and public-install verification sequence and is -available from npm's `next` channel. Stable `0.1.1` is available from npm's -default `latest` channel with verified provenance and public-install evidence. +authorized live-smoke evidence. Registry Alpha began when the functional +`0.1.0-alpha.1` prerelease was published. The immutable `0.1.0-alpha.2` GitHub +Release then failed before invoking npm because its publication guard rejected +the fixed `actions/setup-node` authentication placeholder. +`0.1.0-alpha.3` subsequently completed the OIDC, provenance, ownership, and +public-install verification sequence. Stable `0.1.1` completed its separately +recorded release sequence on 2026-07-30. Stable 0.1.x packages use `latest`, and +Registry Alpha artifacts use `next`; query npm and GitHub rather than treating +this roadmap as current registry state. ## Milestones @@ -263,12 +265,13 @@ Explicit non-goals: ## 0.1.1: Options Contract and Release Repair (Complete) -Stable `0.1.1` aligns the declarations with the supported 0.1 runtime -boundary. `CometAPIOptions` and `withOptions` exclude `provider`, -`workloadIdentity`, and `dangerouslyAllowBrowser`; runtime objects that bypass -the declarations receive a secret-free official `OpenAIError`. Existing -transport, observability, organization, project, webhook, and admin-key options -remain available. The supported resource list does not change. +Stable `0.1.1` omitted `provider`, `workloadIdentity`, and +`dangerouslyAllowBrowser` from `CometAPIOptions`, constrained `withOptions`, and +rejected runtime bypasses with a secret-free official `OpenAIError`. That +declaration rejected fresh object literals but still admitted structurally +typed variables; runtime enforcement remained intact. Existing transport, +observability, organization, project, webhook, and admin-key options remained +available, and the supported resource list did not change. The patch also installs the permanent stable Release Please path with explicit component identity, patch versioning, pull-request configuration, normal @@ -283,15 +286,16 @@ approval, and declaration or runtime option bypasses. Completion evidence: -- Source and packed ESM/CommonJS declarations pass executed TypeScript negative - tests, while runtime bypass tests preserve official error identity and do not - expose option values. +- Source and packed ESM/CommonJS declarations passed the then-required fresh + object-literal TypeScript negative tests, while runtime bypass tests preserved + official error identity and did not expose option values. - The repair PR and generated `0.1.1` release PR passed required CI on their exact final heads, and a distinct human administrator formally approved the action-authored release PR's final head. - Release Please created the exact immutable `v0.1.1` Release. The bounded three-request live smoke and npm OIDC publication completed through the - disclosed one-time recovery without changing `next=0.1.0-alpha.3`. + disclosed one-time recovery without changing the Registry Alpha `next` + dist-tag. - A public-registry install verifies ESM, CommonJS, declarations, supported mocked calls, one effective OpenAI installation, API error identity, integrity, signatures, and provenance. @@ -300,6 +304,27 @@ Completion evidence: recovery-path cleanup. The evidence is recorded in [RELEASING.md](./RELEASING.md#stable-011-release-evidence). +## Stable 0.1.x Maintenance + +Maintenance patches close contract and release-process gaps without expanding +the supported resource surface. The active maintenance work redeclares the +three reserved client controls as optional `never`, executes negative tests for +variables, spreads, `satisfies ClientOptions`, and constrained generics through +both the constructor and `withOptions`, and preserves the runtime guard for +plain JavaScript and casts. + +Durable README, agent, compatibility, roadmap, and release guidance uses 0.1.x +capability and channel language instead of copying a mutable exact version from +the registry. `package.json` is the sole candidate-version authority, the packed +README must match the reviewed source byte-for-byte, and the release gates +reject exact-version current, approval, unpublished, or in-progress claims. + +This maintenance work is complete only after the normal four-file Release +Please PR, immutable tag and GitHub Release, fresh bounded live smoke, tag-bound +npm OIDC publication, and independent public-registry installation all pass. +Release-specific evidence is recorded after publication rather than predicted +in advance. + ## 0.2.0: Provider-Native Text Adapters Planned scope: @@ -380,14 +405,18 @@ a temporary `release-as: 0.1.0-alpha.1` setting. Its reviewed pull request must record that version in the manifest and remove `release-as` before merge so later Release Please prereleases advance normally. -`package.json` is the local version source. Package validation derives the -version from it and checks the lock root, Release Please manifest-or-bootstrap, -single changelog heading, and packed metadata. Publication additionally checks -the immutable tag and GitHub release and fails closed while the license, -README release status, security or support contacts, or repository identity -still contain unresolved or candidate-only values. The publish workflow alone -selects the npm dist-tag: `next` for prereleases and `latest` for stable -versions. A static `publishConfig.tag` is forbidden. +`package.json` is the sole candidate-version authority. Package validation +derives the version from it and checks the lock root, Release Please +manifest-or-bootstrap, single changelog heading, and packed metadata. A normal +stable Release Please PR changes exactly `.release-please-manifest.json`, +`CHANGELOG.md`, `package-lock.json`, and `package.json`; durable documentation +uses 0.1.x capability and channel language instead of candidate-specific +status. Publication additionally checks the immutable tag and GitHub Release +and fails closed while identity or public documentation contains unresolved or +candidate-only values. The publish workflow alone selects the npm dist-tag: +`next` for prereleases and `latest` for stable versions. A static +`publishConfig.tag` is forbidden. Exact public state must be queried from npm +and GitHub. Scheduled live smoke requires `LIVE_SMOKE_ENABLED=true`; an unset or other value prevents live execution. Release Please requires diff --git a/eslint.config.js b/eslint.config.js index dc9371a..ca40864 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -9,8 +9,8 @@ export default tseslint.config( ".cache/**", "coverage/**", "dist/**", - "fixtures/openai-host/consumer.cts", - "fixtures/openai-host/consumer.mts", + "fixtures/openai-host/*consumer.cts", + "fixtures/openai-host/*consumer.mts", "node_modules/**", ], }, diff --git a/fixtures/openai-host/consumer.cts b/fixtures/openai-host/consumer.cts index 974bc8f..314c0a5 100644 --- a/fixtures/openai-host/consumer.cts +++ b/fixtures/openai-host/consumer.cts @@ -17,6 +17,14 @@ const options: CometAPIOptions = { }; const client = new CometAPI(options); const upstream: OpenAI = client; +const supportedOverrides: Partial = { + defaultHeaders: { "x-fixture": "supported" }, + maxRetries: 0, + timeout: 1_000, +}; +client.withOptions(supportedOverrides); +new CometAPI({ ...options }); +client.withOptions({ ...supportedOverrides }); const unsupportedProviderOptions: CometAPIOptions = { // @ts-expect-error CometAPI does not expose OpenAI provider routing. @@ -44,6 +52,111 @@ client.withOptions({ dangerouslyAllowBrowser: true, }); +const inferredProviderOptions = { + provider: {} as NonNullable, + timeout: 1_001, +}; +const inferredWorkloadIdentityOptions = { + timeout: 1_002, + workloadIdentity: {} as NonNullable, +}; +const inferredBrowserOptions = { + dangerouslyAllowBrowser: true, + timeout: 1_003, +}; + +// @ts-expect-error Inferred variables must not bypass provider routing. +new CometAPI(inferredProviderOptions); +// @ts-expect-error Inferred variables must not bypass workload authentication. +new CometAPI(inferredWorkloadIdentityOptions); +// @ts-expect-error Inferred variables must not bypass the browser boundary. +new CometAPI(inferredBrowserOptions); +// @ts-expect-error withOptions must reject inferred provider variables. +client.withOptions(inferredProviderOptions); +// @ts-expect-error withOptions must reject inferred workload variables. +client.withOptions(inferredWorkloadIdentityOptions); +// @ts-expect-error withOptions must reject inferred browser variables. +client.withOptions(inferredBrowserOptions); + +const providerOptionsSatisfyingClientOptions = { + provider: {} as NonNullable, + timeout: 1_004, +} satisfies ClientOptions; +const workloadOptionsSatisfyingClientOptions = { + timeout: 1_005, + workloadIdentity: {} as NonNullable, +} satisfies ClientOptions; +const browserOptionsSatisfyingClientOptions = { + dangerouslyAllowBrowser: true, + timeout: 1_006, +} satisfies ClientOptions; + +// @ts-expect-error ClientOptions variables must not bypass provider routing. +new CometAPI(providerOptionsSatisfyingClientOptions); +// @ts-expect-error ClientOptions variables must not bypass workload authentication. +new CometAPI(workloadOptionsSatisfyingClientOptions); +// @ts-expect-error ClientOptions variables must not bypass the browser boundary. +new CometAPI(browserOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions provider variables. +client.withOptions(providerOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions workload variables. +client.withOptions(workloadOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions browser variables. +client.withOptions(browserOptionsSatisfyingClientOptions); + +// @ts-expect-error Visible spreads must not bypass provider routing. +new CometAPI({ ...inferredProviderOptions }); +// @ts-expect-error Visible spreads must not bypass workload authentication. +new CometAPI({ ...inferredWorkloadIdentityOptions }); +// @ts-expect-error Visible spreads must not bypass the browser boundary. +new CometAPI({ ...inferredBrowserOptions }); +// @ts-expect-error withOptions must reject visible provider spreads. +client.withOptions({ ...inferredProviderOptions }); +// @ts-expect-error withOptions must reject visible workload spreads. +client.withOptions({ ...inferredWorkloadIdentityOptions }); +// @ts-expect-error withOptions must reject visible browser spreads. +client.withOptions({ ...inferredBrowserOptions }); + +function rejectProviderFromConstrainedGeneric< + T extends { + provider: NonNullable; + timeout: number; + }, +>(genericOptions: T): T { + // @ts-expect-error Constrained generics must not bypass provider routing. + new CometAPI(genericOptions); + // @ts-expect-error withOptions must reject provider-constrained generics. + client.withOptions(genericOptions); + return genericOptions; +} + +function rejectWorkloadFromConstrainedGeneric< + T extends { + timeout: number; + workloadIdentity: NonNullable; + }, +>(genericOptions: T): T { + // @ts-expect-error Constrained generics must not bypass workload authentication. + new CometAPI(genericOptions); + // @ts-expect-error withOptions must reject workload-constrained generics. + client.withOptions(genericOptions); + return genericOptions; +} + +function rejectBrowserFromConstrainedGeneric< + T extends { dangerouslyAllowBrowser: boolean; timeout: number }, +>(genericOptions: T): T { + // @ts-expect-error Constrained generics must not bypass the browser boundary. + new CometAPI(genericOptions); + // @ts-expect-error withOptions must reject browser-constrained generics. + client.withOptions(genericOptions); + return genericOptions; +} + +rejectProviderFromConstrainedGeneric(inferredProviderOptions); +rejectWorkloadFromConstrainedGeneric(inferredWorkloadIdentityOptions); +rejectBrowserFromConstrainedGeneric(inferredBrowserOptions); + const chat: APIPromise = client.chat.completions.create({ messages: [{ content: "Reply with OK.", role: "user" }], model: "gpt-5.4", diff --git a/fixtures/openai-host/consumer.mts b/fixtures/openai-host/consumer.mts index 974bc8f..314c0a5 100644 --- a/fixtures/openai-host/consumer.mts +++ b/fixtures/openai-host/consumer.mts @@ -17,6 +17,14 @@ const options: CometAPIOptions = { }; const client = new CometAPI(options); const upstream: OpenAI = client; +const supportedOverrides: Partial = { + defaultHeaders: { "x-fixture": "supported" }, + maxRetries: 0, + timeout: 1_000, +}; +client.withOptions(supportedOverrides); +new CometAPI({ ...options }); +client.withOptions({ ...supportedOverrides }); const unsupportedProviderOptions: CometAPIOptions = { // @ts-expect-error CometAPI does not expose OpenAI provider routing. @@ -44,6 +52,111 @@ client.withOptions({ dangerouslyAllowBrowser: true, }); +const inferredProviderOptions = { + provider: {} as NonNullable, + timeout: 1_001, +}; +const inferredWorkloadIdentityOptions = { + timeout: 1_002, + workloadIdentity: {} as NonNullable, +}; +const inferredBrowserOptions = { + dangerouslyAllowBrowser: true, + timeout: 1_003, +}; + +// @ts-expect-error Inferred variables must not bypass provider routing. +new CometAPI(inferredProviderOptions); +// @ts-expect-error Inferred variables must not bypass workload authentication. +new CometAPI(inferredWorkloadIdentityOptions); +// @ts-expect-error Inferred variables must not bypass the browser boundary. +new CometAPI(inferredBrowserOptions); +// @ts-expect-error withOptions must reject inferred provider variables. +client.withOptions(inferredProviderOptions); +// @ts-expect-error withOptions must reject inferred workload variables. +client.withOptions(inferredWorkloadIdentityOptions); +// @ts-expect-error withOptions must reject inferred browser variables. +client.withOptions(inferredBrowserOptions); + +const providerOptionsSatisfyingClientOptions = { + provider: {} as NonNullable, + timeout: 1_004, +} satisfies ClientOptions; +const workloadOptionsSatisfyingClientOptions = { + timeout: 1_005, + workloadIdentity: {} as NonNullable, +} satisfies ClientOptions; +const browserOptionsSatisfyingClientOptions = { + dangerouslyAllowBrowser: true, + timeout: 1_006, +} satisfies ClientOptions; + +// @ts-expect-error ClientOptions variables must not bypass provider routing. +new CometAPI(providerOptionsSatisfyingClientOptions); +// @ts-expect-error ClientOptions variables must not bypass workload authentication. +new CometAPI(workloadOptionsSatisfyingClientOptions); +// @ts-expect-error ClientOptions variables must not bypass the browser boundary. +new CometAPI(browserOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions provider variables. +client.withOptions(providerOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions workload variables. +client.withOptions(workloadOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions browser variables. +client.withOptions(browserOptionsSatisfyingClientOptions); + +// @ts-expect-error Visible spreads must not bypass provider routing. +new CometAPI({ ...inferredProviderOptions }); +// @ts-expect-error Visible spreads must not bypass workload authentication. +new CometAPI({ ...inferredWorkloadIdentityOptions }); +// @ts-expect-error Visible spreads must not bypass the browser boundary. +new CometAPI({ ...inferredBrowserOptions }); +// @ts-expect-error withOptions must reject visible provider spreads. +client.withOptions({ ...inferredProviderOptions }); +// @ts-expect-error withOptions must reject visible workload spreads. +client.withOptions({ ...inferredWorkloadIdentityOptions }); +// @ts-expect-error withOptions must reject visible browser spreads. +client.withOptions({ ...inferredBrowserOptions }); + +function rejectProviderFromConstrainedGeneric< + T extends { + provider: NonNullable; + timeout: number; + }, +>(genericOptions: T): T { + // @ts-expect-error Constrained generics must not bypass provider routing. + new CometAPI(genericOptions); + // @ts-expect-error withOptions must reject provider-constrained generics. + client.withOptions(genericOptions); + return genericOptions; +} + +function rejectWorkloadFromConstrainedGeneric< + T extends { + timeout: number; + workloadIdentity: NonNullable; + }, +>(genericOptions: T): T { + // @ts-expect-error Constrained generics must not bypass workload authentication. + new CometAPI(genericOptions); + // @ts-expect-error withOptions must reject workload-constrained generics. + client.withOptions(genericOptions); + return genericOptions; +} + +function rejectBrowserFromConstrainedGeneric< + T extends { dangerouslyAllowBrowser: boolean; timeout: number }, +>(genericOptions: T): T { + // @ts-expect-error Constrained generics must not bypass the browser boundary. + new CometAPI(genericOptions); + // @ts-expect-error withOptions must reject browser-constrained generics. + client.withOptions(genericOptions); + return genericOptions; +} + +rejectProviderFromConstrainedGeneric(inferredProviderOptions); +rejectWorkloadFromConstrainedGeneric(inferredWorkloadIdentityOptions); +rejectBrowserFromConstrainedGeneric(inferredBrowserOptions); + const chat: APIPromise = client.chat.completions.create({ messages: [{ content: "Reply with OK.", role: "user" }], model: "gpt-5.4", diff --git a/fixtures/openai-host/exact-optional-consumer.cts b/fixtures/openai-host/exact-optional-consumer.cts new file mode 100644 index 0000000..439e83e --- /dev/null +++ b/fixtures/openai-host/exact-optional-consumer.cts @@ -0,0 +1,16 @@ +import { CometAPI } from "cometapi"; + +const client = new CometAPI({ apiKey: "fixture-key" }); + +// @ts-expect-error Exact optional declarations must reject an explicit provider key. +new CometAPI({ provider: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit workload key. +new CometAPI({ workloadIdentity: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit browser key. +new CometAPI({ dangerouslyAllowBrowser: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit provider key. +client.withOptions({ provider: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit workload key. +client.withOptions({ workloadIdentity: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit browser key. +client.withOptions({ dangerouslyAllowBrowser: undefined }); diff --git a/fixtures/openai-host/exact-optional-consumer.mts b/fixtures/openai-host/exact-optional-consumer.mts new file mode 100644 index 0000000..439e83e --- /dev/null +++ b/fixtures/openai-host/exact-optional-consumer.mts @@ -0,0 +1,16 @@ +import { CometAPI } from "cometapi"; + +const client = new CometAPI({ apiKey: "fixture-key" }); + +// @ts-expect-error Exact optional declarations must reject an explicit provider key. +new CometAPI({ provider: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit workload key. +new CometAPI({ workloadIdentity: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit browser key. +new CometAPI({ dangerouslyAllowBrowser: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit provider key. +client.withOptions({ provider: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit workload key. +client.withOptions({ workloadIdentity: undefined }); +// @ts-expect-error Exact optional declarations must reject an explicit browser key. +client.withOptions({ dangerouslyAllowBrowser: undefined }); diff --git a/fixtures/openai-host/package.json b/fixtures/openai-host/package.json index a5e049a..4b078a6 100644 --- a/fixtures/openai-host/package.json +++ b/fixtures/openai-host/package.json @@ -4,7 +4,7 @@ "type": "module", "scripts": { "test": "npm run typecheck && node verify.mjs && node verify.cjs", - "typecheck": "tsc --noEmit --project tsconfig.json" + "typecheck": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.nonexact.json" }, "dependencies": { "openai": "6.47.0" diff --git a/fixtures/openai-host/tsconfig.json b/fixtures/openai-host/tsconfig.json index 43a5771..ddfc4c1 100644 --- a/fixtures/openai-host/tsconfig.json +++ b/fixtures/openai-host/tsconfig.json @@ -10,5 +10,10 @@ "strict": true, "target": "ES2022" }, - "include": ["consumer.mts", "consumer.cts"] + "include": [ + "consumer.mts", + "consumer.cts", + "exact-optional-consumer.mts", + "exact-optional-consumer.cts" + ] } diff --git a/fixtures/openai-host/tsconfig.nonexact.json b/fixtures/openai-host/tsconfig.nonexact.json new file mode 100644 index 0000000..e8fc513 --- /dev/null +++ b/fixtures/openai-host/tsconfig.nonexact.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false + }, + "include": ["consumer.mts", "consumer.cts"] +} diff --git a/scripts/release-validation.mjs b/scripts/release-validation.mjs index 873bfbe..006bcec 100644 --- a/scripts/release-validation.mjs +++ b/scripts/release-validation.mjs @@ -9,6 +9,12 @@ const SEMVER_PATTERN = new RegExp( `(?:-(${PRERELEASE_IDENTIFIER}(?:\\.${PRERELEASE_IDENTIFIER})*))?` + `(?:\\+(${BUILD_IDENTIFIER}(?:\\.${BUILD_IDENTIFIER})*))?$`, ); +const SEMVER_TOKEN_PATTERN = new RegExp( + `(? markdownCodeValues(child, values)); + } + return values; +} + +function markdownReleaseStatements(text) { + const statements = []; + const visit = (node) => { + if (node.type === "code" || node.type === "html") return; + if (node.type === "heading" || node.type === "paragraph") { + statements.push({ + line: node.position?.start.line ?? 1, + text: markdownNodeText(node), + }); + return; + } + if (Array.isArray(node.children)) node.children.forEach(visit); + }; + visit(fromMarkdown(text)); + return statements; +} + +function releaseClaimWindow(statement, start, end) { + let windowStart = 0; + for (let index = start - 1; index >= 0; index -= 1) { + const character = statement[index]; + if ( + character === "\n" || + character === ";" || + character === "!" || + character === "?" || + (character === "." && /\s/.test(statement[index + 1] ?? "")) + ) { + windowStart = index + 1; + break; + } + } + + let windowEnd = statement.length; + for (let index = end; index < statement.length; index += 1) { + const character = statement[index]; + if ( + character === "\n" || + character === ";" || + character === "!" || + character === "?" || + (character === "." && + (index === statement.length - 1 || + /\s/.test(statement[index + 1] ?? ""))) + ) { + windowEnd = index + 1; + break; + } + } + return statement.slice(windowStart, windowEnd).trim(); +} + +function isThirdPartyVersion(statement, versionStart) { + return THIRD_PARTY_VERSION_PREFIX.test( + statement.slice(Math.max(0, versionStart - 96), versionStart), + ); +} + +function replaceSemanticVersionTokens(value) { + return value.replace( + new RegExp(SEMVER_TOKEN_PATTERN.source, SEMVER_TOKEN_PATTERN.flags), + "COMETAPI_VERSION", + ); +} + +function isMutablePublishedVersionClaim(statement, match) { + if (isThirdPartyVersion(statement, match.index)) return false; + const window = releaseClaimWindow( + statement, + match.index, + match.index + match[0].length, + ); + const normalizedWindow = replaceSemanticVersionTokens(window); + if (HISTORICAL_RELEASE_CONTEXT.test(normalizedWindow)) return false; + if ( + MUTABLE_RELEASE_STATE_PATTERNS.some((pattern) => + pattern.test(normalizedWindow), + ) + ) { + return true; + } + if (!statement.includes("|")) return false; + return MUTABLE_RELEASE_TABLE_PATTERN.test( + replaceSemanticVersionTokens(statement), + ); +} + +export function collectMutablePublishedVersionClaims(documents = {}) { + const violations = []; + for (const [filename, text] of Object.entries(documents)) { + if (typeof text !== "string") continue; + for (const statement of markdownReleaseStatements(text)) { + for (const match of statement.text.matchAll(SEMVER_TOKEN_PATTERN)) { + if (isMutablePublishedVersionClaim(statement.text, match)) { + violations.push( + `${filename}:${String(statement.line)}: contains a mutable exact-version publication claim; use capability/channel wording and query npm for current registry state.`, + ); + break; + } + } + } + } + return violations; +} + +export function validatePublicationNeutralReadme(readme) { + if (typeof readme !== "string" || readme.trim().length === 0) { + throw new Error( + "README.md must contain publication-neutral release guidance.", + ); + } + const document = parseMarkdownDocument(readme); + const commandText = [ + document.text, + ...markdownCodeValues(document.root), + ].join("\n"); + const mutableClaims = collectMutablePublishedVersionClaims({ + "README.md": readme, + }); + if (mutableClaims.length > 0) throw new Error(mutableClaims[0]); + if ( + README_RELEASE_STATE_PATTERNS.some((pattern) => pattern.test(document.text)) + ) { + throw new Error( + "README.md must not contain approval, unpublished, or in-progress release state.", + ); + } + if (/^\s*npm\s+install\s+cometapi@\S+\s*$/im.test(commandText)) { + throw new Error( + "README.md must not contain an exact or tagged npm install command.", + ); + } + if (!/^\s*npm\s+install\s+cometapi\s*$/im.test(commandText)) { + throw new Error( + "README.md must contain the unpinned stable install command 'npm install cometapi'.", + ); + } + if ( + /https:\/\/www\.npmjs\.com\/package\/cometapi\/v\/v?\d+\.\d+\.\d+/i.test( + document.referenceText, + ) + ) { + throw new Error("README.md must not contain a versioned npm package URL."); + } + if ( + !/https:\/\/www\.npmjs\.com\/package\/cometapi(?:[\s/)#?]|$)/i.test( + document.referenceText, + ) + ) { + throw new Error( + "README.md must link to the unversioned cometapi npm package page.", + ); + } + if ( + new RegExp(SEMVER_TOKEN_PATTERN.source, SEMVER_TOKEN_PATTERN.flags).test( + [document.referenceText, ...markdownCodeValues(document.root)].join("\n"), + ) + ) { + throw new Error( + "README.md must not pin exact package versions; use release-line guidance and registry queries.", + ); + } +} + function isISODate(value) { const date = new Date(`${value}T00:00:00.000Z`); return ( @@ -533,6 +739,16 @@ export function collectPublicPreviewViolations({ }); } + violations.push( + ...collectMutablePublishedVersionClaims( + Object.fromEntries( + PUBLIC_DOCUMENTS.filter(([field]) => field !== "readme").map( + ([field, filename]) => [filename, documents[field]], + ), + ), + ), + ); + collectViolation(violations, () => requireExact( sourceManifest?.name, @@ -639,6 +855,9 @@ export function collectPublicPreviewViolations({ const readmeDocument = markdownDocuments.get("readme"); if (readmeDocument !== undefined) { + collectViolation(violations, () => + validatePublicationNeutralReadme(documents.readme), + ); const firstSection = markdownHeadings(readmeDocument).find( ({ level, nodeIndex }) => nodeIndex > 0 && level >= 2, ); @@ -648,7 +867,9 @@ export function collectPublicPreviewViolations({ .join("\n"); collectViolation(violations, () => { const { isPrerelease } = parseSemanticVersion(sourceManifest?.version); - const labelsPrerelease = /\bpre(?:-|\s)?release\b/i.test(preamble); + const labelsPrerelease = /(?:^|\n)\s*pre(?:-|\s)?release\s*:/i.test( + preamble, + ); if (isPrerelease && !labelsPrerelease) { throw new Error( "README.md must label the project as a pre-release near the top of the document.", @@ -778,6 +999,18 @@ function releaseChangelogSection(changelog, version) { } export function validateReleasableDocuments({ changelog, documents, version }) { + const mutableClaims = collectMutablePublishedVersionClaims( + Object.fromEntries( + PUBLIC_DOCUMENTS.map(([field, filename]) => [ + filename, + documents?.[field], + ]), + ), + ); + if (mutableClaims.length > 0) { + throw new Error(mutableClaims.join("\n")); + } + const license = assertDocumentText(documents, "license", "LICENSE"); assertNoOwnerPlaceholder(license, "LICENSE"); if (!/copyright\s+(?:\(c\)|©)?\s*\d{4}(?:-\d{4})?\s+\S+/i.test(license)) { @@ -786,20 +1019,11 @@ export function validateReleasableDocuments({ changelog, documents, version }) { ); } - const readmeDocument = parseMarkdownDocument( - assertDocumentText(documents, "readme", "README.md"), - ); + const readme = assertDocumentText(documents, "readme", "README.md"); + validatePublicationNeutralReadme(readme); + const readmeDocument = parseMarkdownDocument(readme); assertNoOwnerPlaceholder(readmeDocument.referenceText, "README.md"); assertNoStalePublicationState(readmeDocument.text, "README.md"); - const approvalPattern = new RegExp( - `\\b${escapeRegularExpression(version)}\\s+is\\s+approved\\s+for\\s+npm\\s+publication\\b`, - "i", - ); - if (!approvalPattern.test(readmeDocument.text)) { - throw new Error( - "README.md must explicitly state ' is approved for npm publication' before tagging.", - ); - } const securityDocument = parseMarkdownDocument( assertDocumentText(documents, "security", "SECURITY.md"), diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 049f5a5..36408e9 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -10,7 +10,10 @@ import { removeTemporaryDirectory, run, } from "./lib.mjs"; -import { validateReleaseMetadata } from "./release-validation.mjs"; +import { + validatePublicationNeutralReadme, + validateReleaseMetadata, +} from "./release-validation.mjs"; const CANONICAL_AUTHOR = "CometAPI"; const CANONICAL_BUGS = { @@ -67,6 +70,7 @@ const packageLock = readJSON(join(ROOT, "package-lock.json")); const releaseManifest = readJSON(join(ROOT, ".release-please-manifest.json")); const releaseConfig = readJSON(join(ROOT, "release-please-config.json")); const changelog = readFileSync(join(ROOT, "CHANGELOG.md"), "utf8"); +const sourceReadme = readFileSync(join(ROOT, "README.md"), "utf8"); const releaseMetadata = validateReleaseMetadata({ changelog, packageLock, @@ -165,6 +169,17 @@ try { assertCanonicalIdentity(packedManifest, "Packed package.json"); assert.deepEqual(paths, expected); + const packedReadme = readFileSync( + join(packageDirectory, "README.md"), + "utf8", + ); + assert.equal( + packedReadme, + sourceReadme, + "Packed README.md must exactly match the reviewed source document", + ); + validatePublicationNeutralReadme(packedReadme); + for (const file of ["dist/index.js", "dist/index.cjs"]) { const contents = readFileSync(join(packageDirectory, file), "utf8"); assert.doesNotMatch(contents, /CometClient|COMETAPI_ACCESS_TOKEN/); diff --git a/src/client.ts b/src/client.ts index 17a641c..a903c61 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,7 +9,7 @@ const UNSUPPORTED_COMETAPI_OPTIONS = [ ] as const satisfies readonly (keyof ClientOptions)[]; type UnsupportedCometAPIOption = (typeof UNSUPPORTED_COMETAPI_OPTIONS)[number]; -function sanitizeOptions>( +function validateAndSnapshotOptions>( options: T, ): Omit { for (const option of UNSUPPORTED_COMETAPI_OPTIONS) { @@ -54,6 +54,12 @@ export interface CometAPIOptions extends Omit< apiKey?: string; /** OpenAI-compatible API base. Defaults to `COMETAPI_BASE_URL`, then CometAPI. */ baseURL?: string; + /** CometAPI owns provider routing. */ + provider?: never; + /** CometAPI owns API-key authentication. */ + workloadIdentity?: never; + /** Browser-side long-lived key use is unsupported. */ + dangerouslyAllowBrowser?: never; } /** @@ -66,7 +72,7 @@ export interface CometAPIOptions extends Omit< */ export class CometAPI extends OpenAI { constructor(options: CometAPIOptions = {}) { - const supportedOptions = sanitizeOptions(options); + const supportedOptions = validateAndSnapshotOptions(options); const { apiKey: explicitAPIKey, baseURL: explicitBaseURL, @@ -80,6 +86,6 @@ export class CometAPI extends OpenAI { } override withOptions(options: Partial): this { - return super.withOptions(sanitizeOptions(options)); + return super.withOptions(validateAndSnapshotOptions(options)); } } diff --git a/tests/config.test.ts b/tests/config.test.ts index 593194e..e8ad0ed 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -12,6 +12,44 @@ const ENV_KEYS = [ ] as const; const savedEnvironment = new Map(); +const UNSUPPORTED_OPTION_CASES = [ + { + label: "provider object", + optionName: "provider", + unsupportedOptions: { + provider: { credential: "provider-secret-must-not-leak" }, + }, + }, + { + label: "null provider", + optionName: "provider", + unsupportedOptions: { provider: null }, + }, + { + label: "workload identity object", + optionName: "workloadIdentity", + unsupportedOptions: { + workloadIdentity: { + clientSecret: "workload-secret-must-not-leak", + }, + }, + }, + { + label: "null workload identity", + optionName: "workloadIdentity", + unsupportedOptions: { workloadIdentity: null }, + }, + { + label: "enabled browser bypass", + optionName: "dangerouslyAllowBrowser", + unsupportedOptions: { dangerouslyAllowBrowser: true }, + }, + { + label: "disabled browser bypass", + optionName: "dangerouslyAllowBrowser", + unsupportedOptions: { dangerouslyAllowBrowser: false }, + }, +] as const; function createLogger() { return { @@ -214,20 +252,9 @@ describe("CometAPI configuration", () => { ); }); - it.each([ - ["provider", { provider: { credential: "provider-secret-must-not-leak" } }], - [ - "workloadIdentity", - { - workloadIdentity: { - clientSecret: "workload-secret-must-not-leak", - }, - }, - ], - ["dangerouslyAllowBrowser", { dangerouslyAllowBrowser: true }], - ])( - "rejects the unsupported %s constructor option with a secret-free OpenAIError", - (optionName, unsupportedOptions) => { + it.each(UNSUPPORTED_OPTION_CASES)( + "rejects the unsupported $label constructor option with a secret-free OpenAIError", + ({ optionName, unsupportedOptions }) => { const apiKey = "unsupported-constructor-key-must-not-leak"; const logger = createLogger(); const error = captureConfigurationError({ @@ -251,20 +278,9 @@ describe("CometAPI configuration", () => { }, ); - it.each([ - ["provider", { provider: { credential: "provider-secret-must-not-leak" } }], - [ - "workloadIdentity", - { - workloadIdentity: { - clientSecret: "workload-secret-must-not-leak", - }, - }, - ], - ["dangerouslyAllowBrowser", { dangerouslyAllowBrowser: true }], - ])( - "rejects the unsupported %s withOptions override with a secret-free OpenAIError", - (optionName, unsupportedOptions) => { + it.each(UNSUPPORTED_OPTION_CASES)( + "rejects the unsupported $label withOptions override with a secret-free OpenAIError", + ({ optionName, unsupportedOptions }) => { const apiKey = "unsupported-with-options-key-must-not-leak"; const logger = createLogger(); const client = new CometAPI({ apiKey, logger, logLevel: "debug" }); diff --git a/tests/options-types.ts b/tests/options-types.ts index 7214c1e..89be1a7 100644 --- a/tests/options-types.ts +++ b/tests/options-types.ts @@ -20,7 +20,7 @@ const supportedOptions: CometAPIOptions = { }; const client = new CometAPI(supportedOptions); -client.withOptions({ +const supportedOverrides: Partial = { baseURL: "https://derived-options.example.test/v1", defaultHeaders: { "x-derived-options-test": "header" }, defaultQuery: { source: "derived-type-test" }, @@ -29,7 +29,10 @@ client.withOptions({ logger: console, maxRetries: 0, timeout: 2_000, -}); +}; +client.withOptions(supportedOverrides); +new CometAPI({ ...supportedOptions }); +client.withOptions({ ...supportedOverrides }); const providerOptions: CometAPIOptions = { // @ts-expect-error CometAPI owns routing and does not accept OpenAI providers. @@ -56,5 +59,122 @@ client.withOptions({ // @ts-expect-error withOptions must not expose the browser safety bypass. dangerouslyAllowBrowser: true, }); +// @ts-expect-error Exact optional types must reject an explicit provider key. +new CometAPI({ provider: undefined }); +// @ts-expect-error Exact optional types must reject an explicit workload key. +new CometAPI({ workloadIdentity: undefined }); +// @ts-expect-error Exact optional types must reject an explicit browser key. +new CometAPI({ dangerouslyAllowBrowser: undefined }); +// @ts-expect-error Exact optional types must reject an explicit provider key. +client.withOptions({ provider: undefined }); +// @ts-expect-error Exact optional types must reject an explicit workload key. +client.withOptions({ workloadIdentity: undefined }); +// @ts-expect-error Exact optional types must reject an explicit browser key. +client.withOptions({ dangerouslyAllowBrowser: undefined }); + +const inferredProviderOptions = { + provider: {} as NonNullable, + timeout: 1_001, +}; +const inferredWorkloadIdentityOptions = { + timeout: 1_002, + workloadIdentity: {} as NonNullable, +}; +const inferredBrowserOptions = { + dangerouslyAllowBrowser: true, + timeout: 1_003, +}; + +// @ts-expect-error Variables must not bypass CometAPI provider routing. +new CometAPI(inferredProviderOptions); +// @ts-expect-error Variables must not bypass CometAPI workload authentication. +new CometAPI(inferredWorkloadIdentityOptions); +// @ts-expect-error Variables must not bypass the CometAPI browser boundary. +new CometAPI(inferredBrowserOptions); +// @ts-expect-error withOptions variables must not bypass provider routing. +client.withOptions(inferredProviderOptions); +// @ts-expect-error withOptions variables must not bypass workload authentication. +client.withOptions(inferredWorkloadIdentityOptions); +// @ts-expect-error withOptions variables must not bypass the browser boundary. +client.withOptions(inferredBrowserOptions); + +const providerOptionsSatisfyingClientOptions = { + provider: {} as NonNullable, + timeout: 1_004, +} satisfies ClientOptions; +const workloadOptionsSatisfyingClientOptions = { + timeout: 1_005, + workloadIdentity: {} as NonNullable, +} satisfies ClientOptions; +const browserOptionsSatisfyingClientOptions = { + dangerouslyAllowBrowser: true, + timeout: 1_006, +} satisfies ClientOptions; + +// @ts-expect-error ClientOptions variables must not bypass provider routing. +new CometAPI(providerOptionsSatisfyingClientOptions); +// @ts-expect-error ClientOptions variables must not bypass workload authentication. +new CometAPI(workloadOptionsSatisfyingClientOptions); +// @ts-expect-error ClientOptions variables must not bypass the browser boundary. +new CometAPI(browserOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions provider variables. +client.withOptions(providerOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions workload variables. +client.withOptions(workloadOptionsSatisfyingClientOptions); +// @ts-expect-error withOptions must reject ClientOptions browser variables. +client.withOptions(browserOptionsSatisfyingClientOptions); + +// @ts-expect-error Visible spreads must not bypass provider routing. +new CometAPI({ ...inferredProviderOptions }); +// @ts-expect-error Visible spreads must not bypass workload authentication. +new CometAPI({ ...inferredWorkloadIdentityOptions }); +// @ts-expect-error Visible spreads must not bypass the browser boundary. +new CometAPI({ ...inferredBrowserOptions }); +// @ts-expect-error withOptions spreads must not bypass provider routing. +client.withOptions({ ...inferredProviderOptions }); +// @ts-expect-error withOptions spreads must not bypass workload authentication. +client.withOptions({ ...inferredWorkloadIdentityOptions }); +// @ts-expect-error withOptions spreads must not bypass the browser boundary. +client.withOptions({ ...inferredBrowserOptions }); + +function rejectProviderFromConstrainedGeneric< + T extends { + provider: NonNullable; + timeout: number; + }, +>(options: T): T { + // @ts-expect-error Constrained generics must not bypass provider routing. + new CometAPI(options); + // @ts-expect-error withOptions must reject provider-constrained generics. + client.withOptions(options); + return options; +} + +function rejectWorkloadFromConstrainedGeneric< + T extends { + timeout: number; + workloadIdentity: NonNullable; + }, +>(options: T): T { + // @ts-expect-error Constrained generics must not bypass workload authentication. + new CometAPI(options); + // @ts-expect-error withOptions must reject workload-constrained generics. + client.withOptions(options); + return options; +} + +function rejectBrowserFromConstrainedGeneric< + T extends { dangerouslyAllowBrowser: boolean; timeout: number }, +>(options: T): T { + // @ts-expect-error Constrained generics must not bypass the browser boundary. + new CometAPI(options); + // @ts-expect-error withOptions must reject browser-constrained generics. + client.withOptions(options); + return options; +} + +rejectProviderFromConstrainedGeneric(inferredProviderOptions); +rejectWorkloadFromConstrainedGeneric(inferredWorkloadIdentityOptions); +rejectBrowserFromConstrainedGeneric(inferredBrowserOptions); void [browserOptions, providerOptions, workloadIdentityOptions]; diff --git a/tests/publication-neutral-docs.test.mjs b/tests/publication-neutral-docs.test.mjs new file mode 100644 index 0000000..e12ed86 --- /dev/null +++ b/tests/publication-neutral-docs.test.mjs @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; + +import { + collectMutablePublishedVersionClaims, + validatePublicationNeutralReadme, +} from "../scripts/release-validation.mjs"; + +describe("publication-neutral release documents", () => { + it.each([ + "Stable release: 0.1.2.", + "The current npm release is `0.1.2`.", + "`0.1.2` is the latest stable version.", + "npm currently publishes cometapi 0.1.2.", + "Stable `0.1.2` is available from npm.", + "Status: `0.1.2` stable maintenance released.", + "Current milestone: stable 0.1.2.", + "npm latest remains 0.1.2.", + "latest=0.1.2 and next=0.1.0-alpha.3.", + "The `0.1.2` repair is in progress.", + "0.1.2 is approved for npm publication.", + "| Version | Status |\n| --- | --- |\n| `0.1.2` | Current |", + "The newest distribution is **9.8.7**.", + "The current npm release is 8.7.6 and CI passed.", + ])("rejects a mutable exact-version claim: %s", (claim) => { + expect( + collectMutablePublishedVersionClaims({ "README.md": claim }), + ).not.toEqual([]); + }); + + it.each([ + "Stable 0.1.x maintenance releases are available from npm.", + "Stable 0.1.2 completed on 2026-07-30.", + "At the 0.1.2 closeout, npm's latest dist-tag resolved to 0.1.2.", + "The immutable v0.1.2 tag resolves to release commit 710c5649.", + "Artifact cometapi-0.1.2.tgz has SHA-256 deadbeef.", + "For stable 0.1.2, CI run 30515861246 passed.", + "Release 0.2.0 provider adapters are planned.", + "The minimum supported OpenAI version is 6.47.0.", + "Node.js 22.14.0 or later is required for Trusted Publishing.", + "Release Please 17.6.0 generated the historical release.", + "```text\nThe current npm release is 0.1.2.\n```", + "", + "[Release evidence](https://github.com/cometapi-dev/cometapi-node/releases/tag/v0.1.2)", + ])("allows capability, dependency, or immutable evidence: %s", (claim) => { + expect( + collectMutablePublishedVersionClaims({ "RELEASING.md": claim }), + ).toEqual([]); + }); + + it("reports the document and source line", () => { + expect( + collectMutablePublishedVersionClaims({ + "AGENTS.md": "# Agent rules\n\nCurrent npm release: 0.1.2.\n", + }), + ).toEqual([ + expect.stringMatching( + /^AGENTS\.md:3: contains a mutable exact-version publication claim/, + ), + ]); + }); + + it.each([ + "npm install cometapi@0.1.2", + "https://www.npmjs.com/package/cometapi/v/0.1.2", + "0.1.2 is approved for npm publication.", + "0.1.2 remains unpublished.", + "npm latest remains 0.1.1.", + "The 0.1.2 repair is in progress.", + "The immutable v0.1.2 tag is retained as historical evidence.", + ])("rejects README release-state coupling: %s", (claim) => { + expect(() => + validatePublicationNeutralReadme( + `# CometAPI SDK\n\nStable 0.1.x maintenance releases are available from npm.\n\n## Installation\n\n\`\`\`bash\nnpm install cometapi\n\`\`\`\n\nSee https://www.npmjs.com/package/cometapi for current registry state.\n\n${claim}\n`, + ), + ).toThrow(/README\.md/); + }); + + it("accepts an unpinned install and unversioned registry link", () => { + expect(() => + validatePublicationNeutralReadme(`# CometAPI SDK + +Stable 0.1.x maintenance releases are available from npm. + +## Installation + +\`\`\`bash +npm install cometapi +\`\`\` + +See https://www.npmjs.com/package/cometapi for current registry state. +`), + ).not.toThrow(); + }); +}); diff --git a/tests/release-validation.test.mjs b/tests/release-validation.test.mjs index 4ef2d3b..a531eb7 100644 --- a/tests/release-validation.test.mjs +++ b/tests/release-validation.test.mjs @@ -95,7 +95,7 @@ function fixture(version = "0.1.0-alpha.1") { "# Contributing\n\nContributions must include tests.\n\n## Development setup\n\nInstall from the lock file before running checks.\n", license: 'MIT License\n\nCopyright (c) 2026 CometAPI\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software, to use the Software subject to the MIT conditions.\n\nTHE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.\n', - readme: `# CometAPI SDK\n\n${isPrerelease ? "**Pre-release:** the SDK is under active development." : "**Stable:** the SDK is supported for the documented 0.1 surface."} ${version} is approved for npm publication.\n\n## Supported 0.1 surface\n\n- \`chat.completions.create\`, streaming and non-streaming\n- \`responses.create\`, streaming and non-streaming\n- \`models.list\`\n`, + readme: `# CometAPI SDK\n\n${isPrerelease ? "**Pre-release:** development artifacts use the `next` channel." : "**Stable:** stable 0.1.x maintenance releases support the documented surface."}\n\n## Installation\n\n\`\`\`bash\nnpm install cometapi\n\`\`\`\n\nSee https://www.npmjs.com/package/cometapi for current registry state.\n\n## Supported 0.1 surface\n\n- \`chat.completions.create\`, streaming and non-streaming\n- \`responses.create\`, streaming and non-streaming\n- \`models.list\`\n`, releasing: "# Releasing\n\nRelease status is evidence-based.\n\n## Authorization boundary\n\nRemote publication requires maintainer authorization.\n", roadmap: @@ -710,6 +710,42 @@ describe("release metadata validation", () => { }); }); + it("keeps durable README guidance byte-identical across patch releases", () => { + const firstPatch = fixture("0.1.1"); + const secondPatch = fixture("0.1.2"); + firstPatch.changelog = "## [0.1.1] - 2026-07-30\n"; + secondPatch.changelog = "## [0.1.2] - 2026-07-31\n"; + + expect(firstPatch.releaseDocuments.readme).toBe( + secondPatch.releaseDocuments.readme, + ); + for (const values of [firstPatch, secondPatch]) { + expect(() => + validateReleaseMetadata({ + ...values, + requireDatedChangelog: true, + requireFinalReleaseState: true, + requireReleasableDocs: true, + }), + ).not.toThrow(); + } + }); + + it("rejects mutable exact-version claims in non-README release documents", () => { + const values = fixture("0.1.2"); + values.changelog = "## [0.1.2] - 2026-07-31\n"; + values.releaseDocuments.roadmap += "\nThe current npm release is 0.1.2.\n"; + + expect(() => + validateReleaseMetadata({ + ...values, + requireDatedChangelog: true, + requireFinalReleaseState: true, + requireReleasableDocs: true, + }), + ).toThrow(/ROADMAP\.md/); + }); + it.each([ ["component", (config) => delete config.packages["."].component], [ @@ -1000,7 +1036,7 @@ describe("release metadata validation", () => { /README/, ], [ - "missing README approval", + "missing durable README publication guidance", (values) => { values.releaseDocuments.readme = "CometAPI SDK release documentation.\n"; @@ -1080,10 +1116,10 @@ describe("release metadata validation", () => { }); }); - it("does not accept a release approval hidden in a comment", () => { + it("does not accept the stable install command hidden in a comment", () => { const values = fixture(); values.changelog = `## [${values.sourceManifest.version}] - 2026-07-17\n`; - values.releaseDocuments.readme = `# CometAPI SDK\n\n\n`; + values.releaseDocuments.readme = `# CometAPI SDK\n\n\n\nSee https://www.npmjs.com/package/cometapi for current registry state.\n`; expect(() => validateReleaseMetadata({ ...values, @@ -1095,12 +1131,6 @@ describe("release metadata validation", () => { }); it.each([ - [ - "README", - (values) => { - values.releaseDocuments.readme = `# CometAPI SDK\r\r\`\`\`text\r${values.sourceManifest.version} is approved for npm publication.\r\`\`\`\r`; - }, - ], [ "SECURITY", (values) => {