From 8417d73a181c7ebd5436e410a4728ccb5e150a89 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Tue, 28 Jul 2026 16:40:57 +0800 Subject: [PATCH] fix: prepare 0.1.0 stable release --- .github/workflows/ci.yml | 3 + .github/workflows/publish.yml | 1 + .github/workflows/release-please.yml | 3 +- AGENTS.md | 58 ++---- COMPATIBILITY.md | 1 + README.md | 41 +++-- RELEASING.md | 52 ++++-- ROADMAP.md | 11 +- examples/README.md | 9 +- package.json | 1 + release-please-config.json | 5 +- scripts/example-validation.mjs | 75 ++++++++ scripts/fixtures/mock-example-fetch.cjs | 187 ++++++++++++++++++++ scripts/lib.mjs | 2 +- scripts/release-validation.mjs | 223 +++++++++++++++++++++--- scripts/test-examples.mjs | 133 ++++++++++++++ scripts/verify.mjs | 1 + tests/example-validation.test.mjs | 78 +++++++++ tests/release-validation.test.mjs | 189 +++++++++++++++++++- tests/workflow-contract.test.mjs | 15 ++ 20 files changed, 979 insertions(+), 109 deletions(-) create mode 100644 scripts/example-validation.mjs create mode 100644 scripts/fixtures/mock-example-fetch.cjs create mode 100644 scripts/test-examples.mjs create mode 100644 tests/example-validation.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 664e3ac..cb59e7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,8 @@ jobs: run: npm run build - name: Validate the package shape run: npm run test:package + - name: Execute the documented examples against a packed artifact + run: npm run test:examples - name: Verify live-smoke semantic checks with mocked transport run: npm run test:live-contract - name: Test packed-package fixtures @@ -188,6 +190,7 @@ jobs: name: Latest OpenAI 6.x canary / Node.js 24 if: >- github.event_name == 'schedule' || + github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.actor == 'dependabot[bot]') runs-on: ubuntu-latest timeout-minutes: 20 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 331c94d..6cd8ae7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -135,6 +135,7 @@ jobs: npm run test:package -- \ --tarball "${{ steps.pack.outputs.tarball }}" \ --tag "${{ github.event.release.tag_name }}" + npm run test:examples -- --tarball "${{ steps.pack.outputs.tarball }}" npm run test:fixtures -- --tarball "${{ steps.pack.outputs.tarball }}" - name: Upload the verified release artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 97f9b50..0c7ea1e 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -14,7 +14,7 @@ concurrency: jobs: release-please: - name: Prepare or publish a reviewed release + name: Prepare a reviewed release pull request if: vars.RELEASE_PLEASE_ENABLED == 'true' runs-on: ubuntu-latest timeout-minutes: 10 @@ -27,3 +27,4 @@ jobs: with: config-file: release-please-config.json manifest-file: .release-please-manifest.json + skip-github-release: true diff --git a/AGENTS.md b/AGENTS.md index a6c191f..eb56125 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,18 +51,13 @@ 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: Public Preview - -Private Remote Validation and Public Preview pre-visibility preparation are -complete. The repository remains private at the visibility authorization gate. -Repository-local source, tests, documentation, metadata, fixtures, and -workflows may be changed and verified locally. Private topic pushes, pull -requests, merges, and credential-free CI are permitted only when the current -maintainer request explicitly authorizes them; this file records scope and does -not grant that authorization. Stop before changing repository visibility: -making the canonical repository public and configuring or exercising -public-only settings, protected environments, secrets, or live smoke require -separate explicit maintainer authorization. +## Current Milestone: Stable 0.1.0 + +Private Remote Validation, Public Preview, and Registry Alpha are complete. +The canonical repository is public, and `0.1.0-alpha.3` is available from npm's +`next` channel with OIDC provenance and verified public-install evidence. The +current work promotes the existing 0.1 protocol surface to `0.1.0` on npm's +`latest` channel. Do not begin the 0.2 provider adapters during this milestone. The accepted identity is: @@ -82,28 +77,13 @@ 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`. -Before requesting visibility-change authorization: - -1. Confirm the current `main` includes every approved private closeout and that - its blocking Node.js 22 and 24 CI checks pass. -2. Run the complete local offline, package, fixture, compatibility, - self-containment, public-content, secret, workflow-static-validation, and - Public Preview gates from the final private `main`. -3. Review open failing dependency-update pull requests before visibility - changes and repair, close, or explicitly defer them with a recorded reason; - do not treat their branch failures as evidence that the current `main` - failed validation. -4. Confirm the repository remains private and that public-only repository or - tag rules, Private Vulnerability Reporting, secrets, protected environments, - Trusted Publishing, live smoke, tags, releases, and registry publication - have not been configured or exercised. -5. Record the final private evidence and stop for explicit maintainer - authorization before changing repository visibility. - -Private repository creation, the sanitized first history, and the initial push -are complete historical steps and must not be repeated. Their procedure and -evidence belong in `RELEASING.md`. Keep Release Please disabled through the -initial manual alpha. +Stable promotion uses Release Please only for its reviewed version and +changelog pull request. Because the pinned Release Please v5 path is vulnerable +to an upstream single-package tagging defect, it must skip GitHub release +creation. A maintainer creates and reviews the immutable `v0.1.0` release +manually against the exact merged release commit. The publish workflow then +performs exact-artifact verification, the bounded live smoke, npm OIDC +publication, and registry verification. ## Product Contract @@ -211,6 +191,7 @@ npm run lint npm run format:check npm run test:secrets npm run test:package +npm run test:examples npm run test:live-contract npm run test:fixtures npm run test:compat @@ -266,7 +247,7 @@ Before Public Preview, run `npm run check:public-preview`. The gate must fail after reporting all violations until canonical identity, contacts, repository metadata, and durable public-facing content are complete. -### First-publication bootstrap +### First-publication bootstrap history The normal npm release path uses Trusted Publishing on a GitHub-hosted runner with Node.js 22.14.0 or later, npm CLI 11.5.1 or later, and an exact @@ -278,10 +259,9 @@ short-lived granular read/write token with the minimum available scope and non-interactive 2FA bypass from an owner account protected by 2FA. Expose it only through a protected GitHub Environment for one reviewed immutable-tag run, publish with public access and provenance, then immediately configure OIDC, -remove the secret, revoke the token, restrict token-based publishing, and -publish `0.1.0-alpha.2` through OIDC. Verify its provenance and confirm the -`next` dist-tag resolves to `0.1.0-alpha.2`. Never make this exception a -reusable workflow path. +remove the secret, revoke the token, and restrict token-based publishing. This +historical bootstrap ended with `0.1.0-alpha.1`; `0.1.0-alpha.3` subsequently +verified the OIDC-only path. Never make the exception a reusable workflow path. ## Authorization and evidence diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 2c51fc7..e3698c1 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -84,6 +84,7 @@ streaming, and model-list return types. ```bash npm run test npm run test:package +npm run test:examples npm run test:live-contract npm run test:fixtures npm run test:compat diff --git a/README.md b/README.md index cdd4153..a70367b 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ For source-checkout testing, retain and verify one exact tarball: mkdir -p .artifacts npm pack --pack-destination .artifacts npm run test:package -- --tarball .artifacts/cometapi-0.1.0-alpha.3.tgz +npm run test:examples -- --tarball .artifacts/cometapi-0.1.0-alpha.3.tgz npm run test:fixtures -- --tarball .artifacts/cometapi-0.1.0-alpha.3.tgz ``` @@ -93,6 +94,17 @@ const response = await client.responses.create({ }); console.log(response.output_text); + +const stream = await client.chat.completions.create({ + model: "gpt-5.4", + messages: [{ role: "user", content: "Write one sentence about comets." }], + stream: true, +}); + +for await (const chunk of stream) { + process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); +} +process.stdout.write("\n"); ``` ## CommonJS quick start @@ -103,6 +115,12 @@ const { CometAPI } = require("cometapi"); const client = new CometAPI(); async function main() { + const completion = await client.chat.completions.create({ + model: "gpt-5.4", + messages: [{ role: "user", content: "Reply with one short greeting." }], + }); + console.log(completion.choices[0]?.message?.content ?? ""); + const models = await client.models.list(); for (const model of models.data) { console.log(model.id); @@ -115,26 +133,10 @@ main().catch((error) => { }); ``` -## Streaming Chat Completions - -```js -import { CometAPI } from "cometapi"; - -const client = new CometAPI(); -const stream = await client.chat.completions.create({ - model: "gpt-5.4", - messages: [{ role: "user", content: "Write one sentence about comets." }], - stream: true, -}); - -for await (const chunk of stream) { - process.stdout.write(chunk.choices[0]?.delta?.content ?? ""); -} -``` - Runnable ESM and CommonJS examples are in [`examples/`](./examples/). The -packed fixtures execute equivalent mocked public calls; executing live examples -against a packed artifact remains a separately authorized release gate. +offline example gate executes these exact files against the packed artifact with +a fail-closed mocked transport. Executing them against the live API remains a +separately authorized operation. ## Custom options @@ -186,6 +188,7 @@ npm run lint npm run format:check npm run test:secrets npm run test:package +npm run test:examples npm run test:live-contract npm run test:fixtures npm run test:compat diff --git a/RELEASING.md b/RELEASING.md index 8dea0d6..eb9192a 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -82,13 +82,11 @@ evidence is complete only when `npm owner ls cometapi` lists the maintainer-confirmed `cometapi_dev` account; until then this remains a Registry Alpha prerequisite. -For the current Public Preview milestone, private topic pushes, pull requests, -merges, and credential-free CI are the only remote actions that may be -authorized before the visibility transition. This document defines allowable -release mechanics but grants no standing remote-write permission; the current -maintainer request must explicitly authorize each task's remote actions. -Changing repository visibility and every subsequent public-only configuration -or live action require separate authorization. +Public Preview and Registry Alpha are complete. For the current stable +milestone, topic pushes, pull requests, merges, the immutable GitHub Release, +the bounded live smoke, npm publication, and environment approvals still +require authorization from the current maintainer request. This document +defines allowable mechanics but grants no standing remote-write permission. ## Candidate verification gate @@ -103,6 +101,7 @@ npm run lint npm run format:check npm run test:secrets npm run test:package +npm run test:examples npm run test:live-contract npm run test:fixtures npm run test:compat @@ -119,7 +118,10 @@ failures, skipped checks, and unavailable runtime/tool checks. `npm run test:package` builds and inspects a candidate tarball and runs package metadata, export, declaration, `publint`, Are the Types Wrong, and dry-run pack -checks. `npm run test:fixtures` installs a candidate tarball into clean ESM, +checks. `npm run test:examples` installs one exact tarball with the locked OpenAI +version, checks dependency deduplication, and executes the canonical README ESM +and CommonJS examples with fail-closed mocked transport. +`npm run test:fixtures` installs a candidate tarball into clean ESM, CommonJS, and compatible-OpenAI host applications. Both commands accept `--tarball ` so the publication workflow can pack once, inspect and install the exact artifact, then upload that same file. `npm run test:compat` @@ -206,8 +208,8 @@ or publishing the immutable release. Validate the exact committed state with: ```bash node scripts/validate-release.mjs \ - --tag v0.1.0-alpha.1 \ - --release-prerelease true \ + --tag v0.1.0 \ + --release-prerelease false \ --require-final \ --require-releasable-docs ``` @@ -229,10 +231,16 @@ The repository maintains four independently auditable workflows: concurrency group. Scheduled and manual live execution requires `LIVE_SMOKE_ENABLED=true`. - `release-please.yml`: a human-reviewed version and changelog PR from - Conventional Commits and requires `RELEASE_PLEASE_ENABLED=true`. It remains - disabled through the initial manual alpha. Merging a later release PR prepares - a draft GitHub release; a maintainer must review and publish the draft so its - `release.published` event can trigger publication. + Conventional Commits and requires `RELEASE_PLEASE_ENABLED=true`. It uses the + default `GITHUB_TOKEN` and deliberately skips tag and GitHub Release creation. + Because that token does not trigger CI for its generated PR, a maintainer + commits the stable README, security, support, compatibility, and roadmap + state to the generated branch, then manually dispatches `ci.yml` with that + branch as `ref`. Merge is forbidden unless `gh pr checks` reports every + required context on the exact final PR head; if GitHub does not associate the + dispatched checks with that commit, stop rather than bypass protection. + The manual dispatch also runs the latest-compatible OpenAI 6.x lane so the + candidate head has minimum, locked, and latest-within-major evidence. - `publish.yml`: rejects mutable releases and tag commits outside `main`, packs and tests one exact artifact, requires a protected live smoke for that release tag, and publishes the same file through npm OIDC. Registry token credentials @@ -410,6 +418,22 @@ executed README examples against the packed artifact, release-PR/tag/changelog/ manifest version agreement, reviewed security and compatibility status, and post-publication registry evidence. +Release Please is limited to the stable PR because its v5 single-package path +has an open upstream tagging defect when component names are omitted from tags. +After the reviewed release PR merges, create a draft `v0.1.0` GitHub Release +manually against the exact merge commit, review it with `prerelease=false`, and +publish it only once immutable releases are enabled. After successful manual +tagging, change the merged Release Please PR label from `autorelease: pending` +to `autorelease: tagged` so future release PRs are not blocked. + +Release Please does not author the final public status text. After it opens the +`0.1.0` PR, a maintainer pushes a focused documentation commit to that same +branch: README switches from `next`/prerelease to `latest`/stable and records +the exact publication approval; SECURITY and SUPPORT remove prerelease-only +policy; COMPATIBILITY and ROADMAP identify the stable candidate without +claiming it is already released. If Release Please updates the branch again, +repeat the review and CI dispatch against the new final head. + ## Verification record Every release candidate records these evidence layers separately: diff --git a/ROADMAP.md b/ROADMAP.md index 9c0b990..9c2920c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # CometAPI TypeScript and Node.js SDK Roadmap -Status: Public Preview and Registry Alpha complete; 0.1.0 stable planned -Last updated: 2026-07-27 +Status: Public Preview and Registry Alpha complete; 0.1.0 stable in progress +Last updated: 2026-07-28 Repository contract: This roadmap is self-contained and is the public source of truth for this repository's release sequence. @@ -29,7 +29,7 @@ available from npm's `next` channel. | Private Remote Validation | Complete | The sanitized private repository passes real credential-free default-branch CI; public-only controls and live tests remain disabled. | | Public Preview | Complete | The public repository has blocking CI, repository rules, security reporting, protected environments, and authorized live-smoke evidence. | | 0.1.x Registry Alpha | Complete | Early adopters can install a functional, provenance-verified prerelease from npm's `next` channel through the OIDC-only publication path. | -| 0.1.0 Stable | Planned | Users can install a fully verified package from npm's default channel. | +| 0.1.0 Stable | In progress | Users can install a fully verified package from npm's default channel. | | 0.2.0 provider-native text | Planned | Users can opt into Anthropic Messages and Gemini text adapters through isolated subpath exports. | | 0.3.0 CometAPI resources | Planned | Users receive typed access to the first stable CometAPI-specific account or platform resources. | | Media and task APIs | Later | Users receive typed image, video, audio, upload, polling, and task lifecycle helpers after their contracts are stable. | @@ -311,6 +311,11 @@ Stable publication requires a human-reviewed release PR and protected npm environment approval while the SDK remains pre-1.0. Manual workflows may build or dry-run packages but may not publish an arbitrary commit. +For the 0.1.0 promotion, Release Please creates only the reviewed PR and skips +tag and Release creation. After that PR merges, a maintainer manually creates +the immutable `v0.1.0` Release against its exact merge commit; publishing the +Release triggers the existing verified OIDC pipeline. + Public Preview needs no registry workflow. Registry Alpha publishes from a human-reviewed immutable prerelease tag under the `next` dist-tag through OIDC or, only when npm cannot preconfigure Trusted Publishing, through the one-time diff --git a/examples/README.md b/examples/README.md index 3972dd9..4920343 100644 --- a/examples/README.md +++ b/examples/README.md @@ -8,11 +8,12 @@ default example model, `gpt-5.4`. Models. They require `COMETAPI_KEY` for authorized live use. Do not run them against the -live API without an explicit request budget and authorization. Release fixtures -execute equivalent calls with mocked transport against the exact packed -tarball; mocked execution is package evidence, not live compatibility evidence. +live API without an explicit request budget and authorization. The +`test:examples` gate executes these original files with a fail-closed mocked +transport against the exact packed tarball; mocked execution is package +evidence, not live compatibility evidence. -After the Registry Alpha is published under npm's `next` dist-tag: +Registry Alpha is available under npm's `next` dist-tag: ```bash npm install cometapi@next diff --git a/package.json b/package.json index 1046d77..3d24a03 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "test:live": "node scripts/live-smoke.mjs", "test:live-contract": "node scripts/test-live-smoke-contract.mjs", "test:package": "node scripts/test-package.mjs", + "test:examples": "node scripts/test-examples.mjs", "test:fixtures": "node scripts/test-fixtures.mjs", "test:compat": "node scripts/test-compat.mjs", "test:secrets": "node scripts/check-secrets.mjs", diff --git a/release-please-config.json b/release-please-config.json index 674be11..784f6f1 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,9 +4,8 @@ ".": { "release-type": "node", "versioning": "prerelease", - "prerelease-type": "alpha", - "prerelease": true, - "draft": true, + "prerelease": false, + "skip-github-release": true, "changelog-path": "CHANGELOG.md", "include-component-in-tag": false, "include-v-in-tag": true, diff --git a/scripts/example-validation.mjs b/scripts/example-validation.mjs new file mode 100644 index 0000000..813a46c --- /dev/null +++ b/scripts/example-validation.mjs @@ -0,0 +1,75 @@ +import { fromMarkdown } from "mdast-util-from-markdown"; + +function normalizeExample(source) { + return source.replaceAll("\r\n", "\n").replace(/\n$/, ""); +} + +function headingText(node) { + return (node.children ?? []) + .filter((child) => child.type === "text" || child.type === "inlineCode") + .map((child) => child.value) + .join("") + .trim(); +} + +function canonicalBlock(root, title) { + const matches = root.children + .map((node, index) => ({ index, node })) + .filter( + ({ node }) => + node.type === "heading" && + node.depth === 2 && + headingText(node).toLowerCase() === title.toLowerCase(), + ); + if (matches.length !== 1) { + throw new Error( + `README.md must contain exactly one H2 '${title}' section; found ${String(matches.length)}.`, + ); + } + + const { index } = matches[0]; + const end = root.children.findIndex( + (node, nodeIndex) => + nodeIndex > index && node.type === "heading" && node.depth <= 2, + ); + const nodes = root.children.slice(index + 1, end === -1 ? undefined : end); + const blocks = nodes.filter( + (node) => + node.type === "code" && ["js", "javascript"].includes(node.lang ?? ""), + ); + if (blocks.length !== 1) { + throw new Error( + `README.md '${title}' must contain exactly one JavaScript code block; found ${String(blocks.length)}.`, + ); + } + return blocks[0].value; +} + +export function collectReadmeExampleViolations({ readme, esm, commonjs }) { + const violations = []; + let root; + try { + root = fromMarkdown(readme); + } catch (error) { + return [ + `README.md could not be parsed: ${error instanceof Error ? error.message : String(error)}`, + ]; + } + + for (const [title, source, filename] of [ + ["ESM quick start", esm, "examples/esm.mjs"], + ["CommonJS quick start", commonjs, "examples/commonjs.cjs"], + ]) { + try { + if ( + normalizeExample(canonicalBlock(root, title)) !== + normalizeExample(source) + ) { + violations.push(`README.md '${title}' must match ${filename} exactly.`); + } + } catch (error) { + violations.push(error instanceof Error ? error.message : String(error)); + } + } + return violations; +} diff --git a/scripts/fixtures/mock-example-fetch.cjs b/scripts/fixtures/mock-example-fetch.cjs new file mode 100644 index 0000000..3880204 --- /dev/null +++ b/scripts/fixtures/mock-example-fetch.cjs @@ -0,0 +1,187 @@ +const assert = require("node:assert/strict"); +const dgram = require("node:dgram"); +const http = require("node:http"); +const http2 = require("node:http2"); +const https = require("node:https"); +const net = require("node:net"); +const tls = require("node:tls"); +const { URL } = require("node:url"); + +const mode = process.env.COMETAPI_EXAMPLE_MODE; +if (mode !== "esm" && mode !== "commonjs") { + throw new Error("COMETAPI_EXAMPLE_MODE must be esm or commonjs."); +} + +const forbidden = () => { + throw new Error("Real network access is forbidden while testing examples."); +}; +for (const [target, methods] of [ + [http, ["get", "request"]], + [https, ["get", "request"]], + [http2, ["connect"]], + [net, ["connect", "createConnection"]], + [tls, ["connect"]], + [dgram.Socket.prototype, ["connect", "send"]], +]) { + for (const method of methods) target[method] = forbidden; +} +globalThis.WebSocket = function ForbiddenWebSocket() { + forbidden(); +}; + +const jsonHeaders = { "content-type": "application/json" }; +const expectations = + mode === "esm" + ? [ + { + method: "POST", + path: "/v1/responses", + body: { + model: "gpt-5.4", + input: "Explain why the sky is blue in one sentence.", + }, + response: () => + new Response( + JSON.stringify({ + id: "resp_example", + object: "response", + created_at: 1, + status: "completed", + model: "gpt-5.4", + output: [ + { + id: "msg_example", + type: "message", + status: "completed", + role: "assistant", + content: [ + { + type: "output_text", + text: "Sunlight scatters in the atmosphere, making blue wavelengths dominate the daytime sky.", + annotations: [], + logprobs: [], + }, + ], + }, + ], + parallel_tool_calls: true, + tool_choice: "auto", + tools: [], + }), + { headers: jsonHeaders }, + ), + }, + { + method: "POST", + path: "/v1/chat/completions", + body: { + model: "gpt-5.4", + messages: [ + { role: "user", content: "Write one sentence about comets." }, + ], + stream: true, + }, + response: () => + new Response( + [ + 'data: {"id":"chat_example","object":"chat.completion.chunk","created":1,"model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"Comets "},"finish_reason":null}]}', + "", + 'data: {"id":"chat_example","object":"chat.completion.chunk","created":1,"model":"gpt-5.4","choices":[{"index":0,"delta":{"content":"shine."},"finish_reason":null}]}', + "", + "data: [DONE]", + "", + ].join("\n"), + { headers: { "content-type": "text/event-stream" } }, + ), + }, + ] + : [ + { + method: "POST", + path: "/v1/chat/completions", + body: { + model: "gpt-5.4", + messages: [ + { role: "user", content: "Reply with one short greeting." }, + ], + }, + response: () => + new Response( + JSON.stringify({ + id: "chat_example", + object: "chat.completion", + created: 1, + model: "gpt-5.4", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "Hello from CometAPI!", + refusal: null, + }, + finish_reason: "stop", + logprobs: null, + }, + ], + }), + { headers: jsonHeaders }, + ), + }, + { + method: "GET", + path: "/v1/models", + response: () => + new Response( + JSON.stringify({ + object: "list", + data: [ + { + id: "gpt-5.4", + object: "model", + created: 1, + owned_by: "cometapi", + }, + { + id: "claude-sonnet-4-6", + object: "model", + created: 1, + owned_by: "cometapi", + }, + ], + }), + { headers: jsonHeaders }, + ), + }, + ]; + +globalThis.fetch = async (input, init) => { + const request = new Request(input, init); + const expected = expectations[0]; + assert.ok(expected, `Unexpected request: ${request.method} ${request.url}`); + assert.equal(request.method, expected.method); + const url = new URL(request.url); + assert.equal(url.origin, "https://example.invalid"); + assert.equal(url.pathname, expected.path); + assert.equal(url.search, ""); + assert.equal(request.headers.get("authorization"), "Bearer mock-example-key"); + if (expected.body === undefined) { + assert.equal(await request.text(), ""); + } else { + assert.match( + request.headers.get("content-type") ?? "", + /^application\/json/, + ); + assert.deepEqual(JSON.parse(await request.text()), expected.body); + } + expectations.shift(); + return expected.response(); +}; + +process.on("beforeExit", () => { + assert.equal( + expectations.length, + 0, + "The example did not make every expected request.", + ); +}); diff --git a/scripts/lib.mjs b/scripts/lib.mjs index 8b9ccc7..12c68a0 100644 --- a/scripts/lib.mjs +++ b/scripts/lib.mjs @@ -10,7 +10,7 @@ export function run(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd ?? ROOT, encoding: "utf8", - env: { ...process.env, ...options.env }, + env: options.replaceEnv ? options.env : { ...process.env, ...options.env }, stdio: options.capture ? "pipe" : "inherit", }); diff --git a/scripts/release-validation.mjs b/scripts/release-validation.mjs index f1212c3..b8f86aa 100644 --- a/scripts/release-validation.mjs +++ b/scripts/release-validation.mjs @@ -192,15 +192,51 @@ function parseMarkdownDocument(text) { }; } +function isISODate(value) { + const date = new Date(`${value}T00:00:00.000Z`); + return ( + !Number.isNaN(date.valueOf()) && + date.toISOString().slice(0, "YYYY-MM-DD".length) === value + ); +} + +function parseChangelogVersionHeading(heading) { + if (heading.level !== 2 && heading.level !== 3) return undefined; + + const keepAChangelog = /^\[([^\]]+)\] - (Unreleased|\d{4}-\d{2}-\d{2})$/.exec( + heading.title, + ); + const releasePlease = /^v?(\S+) \((\d{4}-\d{2}-\d{2})\)$/.exec(heading.title); + const match = keepAChangelog ?? releasePlease; + if (!match) return undefined; + + try { + parseSemanticVersion(match[1]); + } catch { + return undefined; + } + if (match[2] !== "Unreleased" && !isISODate(match[2])) return undefined; + + return { ...heading, status: match[2], version: match[1] }; +} + +function parseChangelogDocument(changelog) { + const document = parseMarkdownDocument(changelog); + return { + document, + versionHeadings: markdownHeadings(document) + .map(parseChangelogVersionHeading) + .filter((heading) => heading !== undefined), + }; +} + export function visibleMarkdownText(text) { return parseMarkdownDocument(text).text; } function validateChangelog(changelog, version, requireDatedChangelog) { - const escapedVersion = escapeRegularExpression(version); - const headingPattern = new RegExp(`^\\[${escapedVersion}\\](.*)$`); - const headings = markdownHeadings(parseMarkdownDocument(changelog)).filter( - ({ level, title }) => level === 2 && headingPattern.test(title), + const headings = parseChangelogDocument(changelog).versionHeadings.filter( + (heading) => heading.version === version, ); if (headings.length !== 1) { throw new Error( @@ -208,14 +244,7 @@ function validateChangelog(changelog, version, requireDatedChangelog) { ); } - const suffix = headingPattern.exec(headings[0].title)[1]; - const suffixMatch = /^ - (Unreleased|\d{4}-\d{2}-\d{2})$/.exec(suffix); - if (!suffixMatch) { - throw new Error( - `CHANGELOG.md heading for ${version} must be Unreleased or use a YYYY-MM-DD date.`, - ); - } - if (requireDatedChangelog && suffixMatch[1] === "Unreleased") { + if (requireDatedChangelog && headings[0].status === "Unreleased") { throw new Error( `CHANGELOG.md must date ${version} and remove its Unreleased suffix.`, ); @@ -618,11 +647,18 @@ export function collectPublicPreviewViolations({ .map((node) => markdownNodeText(node)) .join("\n"); collectViolation(violations, () => { - if (!/\bpre(?:-|\s)?release\b/i.test(preamble)) { + const { isPrerelease } = parseSemanticVersion(sourceManifest?.version); + const labelsPrerelease = /\bpre(?:-|\s)?release\b/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.", ); } + if (!isPrerelease && labelsPrerelease) { + throw new Error( + "README.md must not label a stable package as a pre-release near the top of the document.", + ); + } }); const supportedSurface = findMarkdownSection( @@ -716,17 +752,29 @@ export function validatePublicPreviewDocuments(input) { } function releaseChangelogSection(changelog, version) { - const document = parseMarkdownDocument(changelog); - const headingPattern = new RegExp( - `^\\[${escapeRegularExpression(version)}\\].*$`, + const { document, versionHeadings } = parseChangelogDocument(changelog); + const headings = versionHeadings.filter( + (heading) => heading.version === version, ); - const heading = markdownHeadings(document).find( - ({ level, title }) => level === 2 && headingPattern.test(title), - ); - if (!heading) { - throw new Error(`CHANGELOG.md has no release section for ${version}.`); + if (headings.length !== 1) { + throw new Error( + `CHANGELOG.md must contain exactly one release section for ${version}; found ${String(headings.length)}.`, + ); } - return findMarkdownSection(document, headingPattern)?.text ?? ""; + const heading = headings[0]; + const nextHeading = markdownHeadings(document).find((candidate) => { + if (candidate.nodeIndex <= heading.nodeIndex) return false; + if (candidate.level < heading.level) return true; + if (candidate.level > heading.level) return false; + return ( + heading.level === 2 || + parseChangelogVersionHeading(candidate) !== undefined + ); + }); + return document.root.children + .slice(heading.nodeIndex + 1, nextHeading?.nodeIndex) + .map((node) => markdownNodeText(node)) + .join("\n"); } export function validateReleasableDocuments({ changelog, documents, version }) { @@ -764,6 +812,18 @@ export function validateReleasableDocuments({ changelog, documents, version }) { ); assertCanonicalContact(supportDocument.referenceText, "SUPPORT.md"); assertNoStalePublicationState(supportDocument.text, "SUPPORT.md"); + if (!parseSemanticVersion(version).isPrerelease) { + for (const [document, filename] of [ + [securityDocument, "SECURITY.md"], + [supportDocument, "SUPPORT.md"], + ]) { + if (/\bpre(?:-|\s)?release(?:s)?\b/i.test(document.text)) { + throw new Error( + `${filename} must remove prerelease-only policy before a stable release.`, + ); + } + } + } const releaseSection = releaseChangelogSection(changelog, version); if ( @@ -777,16 +837,126 @@ export function validateReleasableDocuments({ changelog, documents, version }) { } function validateReleasePleaseState({ + isPrerelease, + prerelease, releaseConfig, releaseManifest, requireFinalReleaseState, version, }) { const packageConfig = releaseConfig?.packages?.["."] ?? {}; + const packageKeys = Object.keys(releaseConfig?.packages ?? {}); + if (packageKeys.length !== 1 || packageKeys[0] !== ".") { + throw new Error( + "Release Please must configure exactly the single root package.", + ); + } + const manifestKeys = Object.keys(releaseManifest ?? {}); + if ( + manifestKeys.length > 1 || + (manifestKeys.length === 1 && manifestKeys[0] !== ".") + ) { + throw new Error( + "Release Please manifest must contain exactly the single root package.", + ); + } const manifestVersion = releaseManifest?.["."]; const hasBootstrapVersion = Object.hasOwn(packageConfig, "release-as"); const bootstrapVersion = packageConfig["release-as"]; + requireExact( + packageConfig["release-type"], + "node", + "Release Please release-type", + ); + requireExact( + packageConfig.versioning, + "prerelease", + "Release Please versioning", + ); + requireExact( + packageConfig["changelog-path"], + "CHANGELOG.md", + "Release Please changelog-path", + ); + requireExact( + packageConfig["include-component-in-tag"], + false, + "Release Please include-component-in-tag", + ); + requireExact( + packageConfig["include-v-in-tag"], + true, + "Release Please include-v-in-tag", + ); + requireExact( + packageConfig["include-v-in-release-name"], + true, + "Release Please include-v-in-release-name", + ); + if (packageConfig["skip-changelog"] === true) { + throw new Error("Release Please must not skip CHANGELOG.md updates."); + } + + let releaseState; + if (isPrerelease && packageConfig.prerelease === true) { + if (prerelease?.split(".")[0] !== "alpha") { + throw new Error( + "Release Please alpha-active state requires alpha prereleases.", + ); + } + requireExact( + packageConfig["prerelease-type"], + prerelease?.split(".")[0], + "Release Please prerelease-type", + ); + releaseState = "alpha-active"; + } else if (isPrerelease && packageConfig.prerelease === false) { + if (!/^0\.1\.0-alpha\.(?:0|[1-9]\d*)$/.test(version)) { + throw new Error( + "Release Please stable promotion requires a 0.1.0-alpha.N package version.", + ); + } + if (Object.hasOwn(packageConfig, "prerelease-type")) { + throw new Error( + "Release Please stable promotion must remove prerelease-type.", + ); + } + requireExact( + packageConfig["skip-github-release"], + true, + "Release Please skip-github-release", + ); + if (Object.hasOwn(packageConfig, "draft")) { + throw new Error("Release Please stable promotion must remove draft."); + } + releaseState = "stable-promotion"; + } else if (!isPrerelease && packageConfig.prerelease === false) { + if (version !== "0.1.0") { + throw new Error( + "Release Please stable candidate must be exactly version 0.1.0.", + ); + } + if (Object.hasOwn(packageConfig, "prerelease-type")) { + throw new Error( + "Release Please stable candidate must remove prerelease-type.", + ); + } + requireExact( + packageConfig["skip-github-release"], + true, + "Release Please skip-github-release", + ); + if (Object.hasOwn(packageConfig, "draft")) { + throw new Error("Release Please stable candidate must remove draft."); + } + releaseState = "stable-candidate"; + } else { + throw new Error( + "Release Please prerelease settings do not match the package release channel.", + ); + } + if (manifestVersion !== undefined) { assertVersion("Release Please manifest", manifestVersion, version); } @@ -795,6 +965,11 @@ function validateReleasePleaseState({ } if (requireFinalReleaseState) { + if (releaseState === "stable-promotion") { + throw new Error( + "A stable-promotion configuration cannot pass final release validation while the package version is still a prerelease.", + ); + } if (manifestVersion !== version) { throw new Error( "Release Please manifest must record the release version before publication.", @@ -810,6 +985,8 @@ function validateReleasePleaseState({ "Release Please must track the source version or explicitly bootstrap it with release-as.", ); } + + return releaseState; } export function validateReleaseMetadata({ @@ -877,6 +1054,8 @@ export function validateReleaseMetadata({ validateChangelog(changelog, version, requireDatedChangelog); validateReleasePleaseState({ + isPrerelease, + prerelease: parsedVersion.prerelease, releaseConfig, releaseManifest, requireFinalReleaseState, diff --git a/scripts/test-examples.mjs b/scripts/test-examples.mjs new file mode 100644 index 0000000..14dc69c --- /dev/null +++ b/scripts/test-examples.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { cpSync, existsSync, mkdirSync, readFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; + +import { + ROOT, + makeTemporaryDirectory, + packCandidate, + readJSON, + removeTemporaryDirectory, + run, +} from "./lib.mjs"; +import { collectReadmeExampleViolations } from "./example-validation.mjs"; + +let supplied; +for (let index = 2; index < process.argv.length; index += 1) { + const argument = process.argv[index]; + if (argument !== "--tarball") { + throw new Error(`Unknown example-test argument: ${String(argument)}`); + } + if (supplied !== undefined) { + throw new Error("--tarball may be supplied only once."); + } + const value = process.argv[index + 1]; + if (!value || value.startsWith("--")) { + throw new Error("--tarball requires a path."); + } + supplied = value; + index += 1; +} +const suppliedTarball = supplied ? resolve(ROOT, supplied) : undefined; +if (suppliedTarball && !existsSync(suppliedTarball)) { + throw new Error(`Packed artifact not found: ${suppliedTarball}`); +} + +const violations = collectReadmeExampleViolations({ + readme: readFileSync(join(ROOT, "README.md"), "utf8"), + esm: readFileSync(join(ROOT, "examples/esm.mjs"), "utf8"), + commonjs: readFileSync(join(ROOT, "examples/commonjs.cjs"), "utf8"), +}); +if (violations.length > 0) throw new Error(violations.join("\n")); + +const temporaryDirectory = makeTemporaryDirectory("cometapi-examples-"); +try { + const tarball = + suppliedTarball ?? + packCandidate(join(temporaryDirectory, "package")).tarball; + const consumer = join(temporaryDirectory, "consumer"); + mkdirSync(consumer); + cpSync(join(ROOT, "examples"), join(consumer, "examples"), { + recursive: true, + }); + cpSync( + join(ROOT, "scripts/fixtures/mock-example-fetch.cjs"), + join(consumer, "mock-example-fetch.cjs"), + ); + const lockedOpenAI = readJSON(join(ROOT, "package-lock.json")).packages[ + "node_modules/openai" + ]?.version; + assert.ok(lockedOpenAI, "package-lock.json must lock OpenAI."); + run("npm", ["init", "--yes"], { cwd: consumer, capture: true }); + run( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + tarball, + `openai@${lockedOpenAI}`, + ], + { cwd: consumer }, + ); + + const dependencyPaths = run("npm", ["ls", "openai", "--all", "--parseable"], { + capture: true, + cwd: consumer, + }) + .trim() + .split("\n") + .filter((path) => path.endsWith("node_modules/openai")); + assert.equal( + dependencyPaths.length, + 1, + "Examples must resolve one OpenAI installation.", + ); + assert.equal( + existsSync(join(consumer, "node_modules/cometapi/node_modules/openai")), + false, + "The example consumer must deduplicate OpenAI.", + ); + + for (const [mode, filename, expectedOutput] of [ + [ + "esm", + "esm.mjs", + "Sunlight scatters in the atmosphere, making blue wavelengths dominate the daytime sky.\nComets shine.\n", + ], + [ + "commonjs", + "commonjs.cjs", + "Hello from CometAPI!\ngpt-5.4\nclaude-sonnet-4-6\n", + ], + ]) { + const output = run( + process.execPath, + [ + "--require", + join(consumer, "mock-example-fetch.cjs"), + join(consumer, "examples", filename), + ], + { + capture: true, + cwd: consumer, + env: { + COMETAPI_BASE_URL: "https://example.invalid/v1", + COMETAPI_EXAMPLE_MODE: mode, + COMETAPI_KEY: "mock-example-key", + PATH: process.env.PATH ?? "", + }, + replaceEnv: true, + }, + ); + assert.equal( + output, + expectedOutput, + `${filename} produced unexpected output.`, + ); + console.log(`Verified ${filename} with ${basename(tarball)}.`); + } +} finally { + removeTemporaryDirectory(temporaryDirectory); +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs index a37cde1..17ff2ff 100644 --- a/scripts/verify.mjs +++ b/scripts/verify.mjs @@ -10,6 +10,7 @@ const offlineChecks = [ "check:standalone-content", "check:public-preview", "test:package", + "test:examples", "test:live-contract", "test:fixtures", ]; diff --git a/tests/example-validation.test.mjs b/tests/example-validation.test.mjs new file mode 100644 index 0000000..1b3c8c6 --- /dev/null +++ b/tests/example-validation.test.mjs @@ -0,0 +1,78 @@ +import { readFileSync } from "node:fs"; +import { URL } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { collectReadmeExampleViolations } from "../scripts/example-validation.mjs"; + +const esm = "console.log('esm');\n"; +const commonjs = "console.log('cjs');\n"; +const readme = `# SDK + +## ESM quick start + +\`\`\`js +${esm}\`\`\` + +## CommonJS quick start + +\`\`\`javascript +${commonjs}\`\`\` +`; + +describe("README example validation", () => { + it("accepts exact canonical examples", () => { + expect(collectReadmeExampleViolations({ readme, esm, commonjs })).toEqual( + [], + ); + }); + + it("normalizes CRLF and one trailing newline", () => { + expect( + collectReadmeExampleViolations({ + readme: readme.replaceAll("\n", "\r\n"), + esm: esm.trimEnd(), + commonjs, + }), + ).toEqual([]); + }); + + it.each([ + [ + "changed code", + readme.replace("console.log('esm')", "console.log('other')"), + ], + [ + "duplicate heading", + `${readme}\n## ESM quick start\n\n\`\`\`js\n${esm}\`\`\`\n`, + ], + [ + "missing code", + readme.replace(`\`\`\`javascript\n${commonjs}\`\`\``, "No code here."), + ], + [ + "code in the next H2", + readme.replace(`\`\`\`js\n${esm}\`\`\``, "No code here."), + ], + ])("rejects %s", (_name, candidate) => { + expect( + collectReadmeExampleViolations({ readme: candidate, esm, commonjs }), + ).not.toEqual([]); + }); + + it("accepts the real repository examples", () => { + expect( + collectReadmeExampleViolations({ + readme: readFileSync(new URL("../README.md", import.meta.url), "utf8"), + esm: readFileSync( + new URL("../examples/esm.mjs", import.meta.url), + "utf8", + ), + commonjs: readFileSync( + new URL("../examples/commonjs.cjs", import.meta.url), + "utf8", + ), + }), + ).toEqual([]); + }); +}); diff --git a/tests/release-validation.test.mjs b/tests/release-validation.test.mjs index 50667f0..861b0c0 100644 --- a/tests/release-validation.test.mjs +++ b/tests/release-validation.test.mjs @@ -45,6 +45,7 @@ function repositoryPublicPreviewFixture() { } function fixture(version = "0.1.0-alpha.1") { + const isPrerelease = version.includes("-"); const sourceManifest = { author: "CometAPI", bugs: { @@ -94,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**Pre-release:** the SDK is under active development. ${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:** 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`, releasing: "# Releasing\n\nRelease status is evidence-based.\n\n## Authorization boundary\n\nRemote publication requires maintainer authorization.\n", roadmap: @@ -104,7 +105,31 @@ function fixture(version = "0.1.0-alpha.1") { support: "# Support\n\nSupport covers the tested SDK surface.\n\n## Getting help\n\nEmail support@cometapi.com or use https://github.com/cometapi-dev/cometapi-node/issues.\n", }, - releaseConfig: { packages: { ".": {} } }, + releaseConfig: { + packages: { + ".": isPrerelease + ? { + "release-type": "node", + versioning: "prerelease", + prerelease: true, + "prerelease-type": "alpha", + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "include-v-in-tag": true, + "include-v-in-release-name": true, + } + : { + "release-type": "node", + versioning: "prerelease", + prerelease: false, + "skip-github-release": true, + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "include-v-in-tag": true, + "include-v-in-release-name": true, + }, + }, + }, releaseManifest: { ".": version }, sourceManifest, }; @@ -514,6 +539,164 @@ describe("release metadata validation", () => { ).toThrow(/tag .* does not match/i); }); + it("accepts stable promotion before the package version changes", () => { + const values = fixture("0.1.0-alpha.3"); + values.releaseConfig.packages["."] = { + "release-type": "node", + versioning: "prerelease", + prerelease: false, + "skip-github-release": true, + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "include-v-in-tag": true, + "include-v-in-release-name": true, + }; + expect(validateReleaseMetadata(values)).toMatchObject({ + version: "0.1.0-alpha.3", + }); + expect(() => + validateReleaseMetadata({ ...values, requireFinalReleaseState: true }), + ).toThrow(/stable-promotion/); + }); + + it("requires the README channel label to match a stable package", () => { + const values = fixture("0.1.0"); + values.releaseDocuments.readme = values.releaseDocuments.readme.replace( + "**Stable:**", + "**Pre-release:**", + ); + expect(() => + validatePublicPreviewDocuments({ + documents: values.releaseDocuments, + sourceManifest: values.sourceManifest, + }), + ).toThrow(/stable package as a pre-release/); + }); + + it.each([ + [ + "stable promotion missing skip", + (config) => delete config["skip-github-release"], + ], + [ + "stable promotion with prerelease type", + (config) => (config["prerelease-type"] = "alpha"), + ], + ["stable promotion with draft", (config) => (config.draft = true)], + ])("rejects %s configuration", (_name, mutate) => { + const values = fixture("0.1.0-alpha.3"); + values.releaseConfig.packages["."] = { + "release-type": "node", + versioning: "prerelease", + prerelease: false, + "skip-github-release": true, + "changelog-path": "CHANGELOG.md", + "include-component-in-tag": false, + "include-v-in-tag": true, + "include-v-in-release-name": true, + }; + mutate(values.releaseConfig.packages["."]); + expect(() => validateReleaseMetadata(values)).toThrow(/Release Please/); + }); + + it.each([ + [ + "Release Please linked H3", + "### [0.1.0](https://github.com/cometapi-dev/cometapi-node/compare/v0.1.0-alpha.3...v0.1.0) (2026-07-28)\n\n### Bug Fixes\n\n* prepare stable\n", + ], + [ + "Release Please plain H2", + "## 0.1.0 (2026-07-28)\n\n### Bug Fixes\n\n* prepare stable\n", + ], + ])("accepts %s changelog output", (_name, changelog) => { + const values = fixture("0.1.0"); + expect(() => + validateReleaseMetadata({ + ...values, + changelog, + requireDatedChangelog: true, + }), + ).not.toThrow(); + }); + + it("rejects duplicate versions across changelog formats", () => { + const values = fixture("0.1.0"); + values.changelog = + "## [0.1.0] - 2026-07-28\n\n### [0.1.0](https://example.invalid) (2026-07-28)\n"; + expect(() => validateReleaseMetadata(values)).toThrow(/found 2/); + }); + + it("scans Release Please H3 subsections up to the next version", () => { + const values = fixture("0.1.0"); + values.changelog = + "### [0.1.0](https://example.invalid) (2026-07-28)\n\n### Bug Fixes\n\nnpm publication: not performed.\n\n## [0.1.0-alpha.3] - 2026-07-27\n\nReleased.\n"; + expect(() => + validateReleaseMetadata({ + ...values, + requireDatedChangelog: true, + requireReleasableDocs: true, + }), + ).toThrow(/CHANGELOG/); + }); + + it("does not let a nested semver heading truncate a release section", () => { + const values = fixture("0.1.0"); + values.changelog = + "## [0.1.0] - 2026-07-28\n\n### 9.9.9 (2026-07-28)\n\nnpm publication: not performed.\n\n## [0.1.0-alpha.3] - 2026-07-27\n"; + expect(() => + validateReleaseMetadata({ + ...values, + requireReleasableDocs: true, + }), + ).toThrow(/CHANGELOG/); + }); + + it.each([ + ["include-v-in-tag", false], + ["include-component-in-tag", true], + ["changelog-path", "OTHER.md"], + ["skip-changelog", true], + ])("rejects a release-critical %s override", (field, value) => { + const values = fixture("0.1.0"); + values.releaseConfig.packages["."][field] = value; + expect(() => validateReleaseMetadata(values)).toThrow(/Release Please/); + }); + + it.each(["0.2.0", "1.0.0"])( + "rejects stable version %s outside the 0.1.0 promotion", + (version) => { + const values = fixture(version); + expect(() => validateReleaseMetadata(values)).toThrow( + /exactly version 0\.1\.0/, + ); + }, + ); + + it("rejects additional Release Please packages", () => { + const values = fixture("0.1.0"); + values.releaseConfig.packages.other = { + ...values.releaseConfig.packages["."], + }; + values.releaseManifest.other = "0.1.0"; + expect(() => validateReleaseMetadata(values)).toThrow( + /single root package/, + ); + }); + + it("rejects prerelease-only support policy for stable publication", () => { + const values = fixture("0.1.0"); + values.changelog = "## [0.1.0] - 2026-07-28\n"; + values.releaseDocuments.support += + "Response times are not guaranteed for prereleases.\n"; + expect(() => + validateReleaseMetadata({ + ...values, + requireDatedChangelog: true, + requireReleasableDocs: true, + }), + ).toThrow(/SUPPORT\.md.*prerelease-only/); + }); + it("escapes every regular-expression metacharacter in versions", () => { expect(escapeRegularExpression("1.2.3-alpha+build.1")).toBe( "1\\.2\\.3-alpha\\+build\\.1", @@ -628,7 +811,7 @@ describe("release metadata validation", () => { "## [0.1.0-alpha.1] - Unreleased\n## [0.1.0-alpha.1] - 2026-07-17\n", /found 2/, ], - ["malformed", "## [0.1.0-alpha.1] - Pending\n", /must be Unreleased/], + ["malformed", "## [0.1.0-alpha.1] - Pending\n", /found 0/], ])("rejects a %s changelog heading", (_name, changelog, message) => { expect(() => validateReleaseMetadata({ ...fixture(), changelog })).toThrow( message, diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index b940b7b..ee6e195 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -64,6 +64,11 @@ describe("GitHub Actions workflow contract", () => { expect(advisory).toMatch(/^ {10}node-version: 26\.x$/m); }); + it("runs the latest OpenAI lane for manually dispatched release-candidate CI", () => { + const latest = job(workflow("ci.yml"), "latest-openai"); + expect(latest).toContain("github.event_name == 'workflow_dispatch'"); + }); + it("runs the Public Preview gate once in blocking CI", () => { expect( matches(workflow("ci.yml"), /^\s*run: npm run check:public-preview$/gm), @@ -166,4 +171,14 @@ describe("GitHub Actions workflow contract", () => { expect(job(publishWorkflow, "live-smoke")).not.toContain("id-token: write"); expect(job(publishWorkflow, "publish")).toContain("id-token: write"); }); + + it("uses Release Please only to prepare the reviewed stable pull request", () => { + const contents = workflow("release-please.yml"); + const releasePlease = job(contents, "release-please"); + expect(releasePlease).toContain( + "googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7", + ); + expect(releasePlease).toContain("skip-github-release: true"); + expect(contents).not.toContain("token:"); + }); });