diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 6cd8ae7..2c7a079 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,11 +1,14 @@ name: Publish on: - release: + workflow_run: + workflows: + - Release Please types: - - published + - completed permissions: + actions: read contents: read concurrency: @@ -15,72 +18,107 @@ concurrency: jobs: verify: name: Verify the immutable release artifact - if: github.event_name == 'release' + if: >- + vars.RELEASE_PLEASE_ENABLED == 'true' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' runs-on: ubuntu-latest timeout-minutes: 30 outputs: dist-tag: ${{ steps.version.outputs.dist-tag }} release-commit: ${{ steps.trust.outputs.release-commit }} + release-tag: ${{ steps.trust.outputs.release-tag }} version: ${{ steps.version.outputs.version }} steps: - - name: Check out the published release tag + - name: Check out the current main branch uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - ref: refs/tags/${{ github.event.release.tag_name }} - - name: Reject an untrusted release target + ref: refs/heads/main + - name: Download the exact Release Please result + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: release-please-result-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }} + path: release-please-result + github-token: ${{ github.token }} + repository: ${{ github.repository }} + run-id: ${{ github.event.workflow_run.id }} + - name: Reject an untrusted Release Please workflow run id: trust env: EXPECTED_BUGS_URL: https://github.com/cometapi-dev/cometapi-node/issues EXPECTED_REPOSITORY: cometapi-dev/cometapi-node EXPECTED_REPOSITORY_URL: git+https://github.com/cometapi-dev/cometapi-node.git - RELEASE_IMMUTABLE: ${{ github.event.release.immutable }} - RELEASE_TAG: ${{ github.event.release.tag_name }} + EXPECTED_WORKFLOW: Release Please + EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml + RELEASE_RESULT: release-please-result/result.json + WORKFLOW_SHA: ${{ github.event.workflow_run.head_sha }} shell: bash run: | set -euo pipefail - if [[ "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" ]]; then - echo "Publication is restricted to $EXPECTED_REPOSITORY; received $GITHUB_REPOSITORY." >&2 - exit 1 - fi - if [[ "$RELEASE_IMMUTABLE" != "true" ]]; then - echo "Publication requires a GitHub release with immutable=true." >&2 - exit 1 - fi - - release_ref="refs/tags/${RELEASE_TAG}" - release_commit="$(git rev-parse --verify "${release_ref}^{commit}")" head_commit="$(git rev-parse HEAD)" - if [[ "$head_commit" != "$release_commit" ]]; then - echo "Checked-out commit $head_commit does not match $release_ref ($release_commit)." >&2 + if [[ "$head_commit" != "$WORKFLOW_SHA" ]]; then + echo "The successful Release Please SHA is no longer the exact main tip." >&2 exit 1 fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then - echo "Release commit $release_commit is not reachable from origin/main." >&2 + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then + echo "origin/main moved after the successful Release Please run." >&2 exit 1 fi - node <<'EOF' - const manifest = require("./package.json"); - const expectedRepository = process.env.EXPECTED_REPOSITORY_URL; - const expectedBugs = process.env.EXPECTED_BUGS_URL; + node --input-type=module <<'EOF' + import { appendFileSync, readFileSync } from "node:fs"; + import { + validateReleasePleaseActionResult, + validateReleaseWorkflowRun, + } from "./scripts/release-workflow-validation.mjs"; + + const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); + const run = validateReleaseWorkflowRun(event, { + checkedOutSha: process.env.WORKFLOW_SHA, + repository: process.env.EXPECTED_REPOSITORY, + workflowName: process.env.EXPECTED_WORKFLOW, + workflowPath: process.env.EXPECTED_WORKFLOW_PATH, + }); + const manifest = JSON.parse(readFileSync("package.json", "utf8")); if ( manifest.repository?.type !== "git" || - manifest.repository?.url !== expectedRepository + manifest.repository?.url !== process.env.EXPECTED_REPOSITORY_URL ) { throw new Error( - `package.json repository must equal ${expectedRepository}.`, + `package.json repository must equal ${process.env.EXPECTED_REPOSITORY_URL}.`, ); } - if (manifest.bugs?.url !== expectedBugs) { - throw new Error(`package.json bugs.url must equal ${expectedBugs}.`); + if (manifest.bugs?.url !== process.env.EXPECTED_BUGS_URL) { + throw new Error(`package.json bugs.url must equal ${process.env.EXPECTED_BUGS_URL}.`); } + const actionResult = JSON.parse( + readFileSync(process.env.RELEASE_RESULT, "utf8"), + ); + const release = validateReleasePleaseActionResult(actionResult, { + releaseCommit: run.releaseCommit, + repository: process.env.EXPECTED_REPOSITORY, + runAttempt: run.runAttempt, + runId: run.runId, + version: manifest.version, + workflowName: process.env.EXPECTED_WORKFLOW, + workflowPath: process.env.EXPECTED_WORKFLOW_PATH, + }); + appendFileSync( + process.env.GITHUB_OUTPUT, + [ + `release-commit=${release.releaseCommit}`, + `release-tag=${release.tag}`, + `release-url=${release.htmlUrl}`, + `release-version=${release.version}`, + "", + ].join("\n"), + ); EOF - - echo "release-commit=${release_commit}" >> "$GITHUB_OUTPUT" - name: Set up Node.js 24 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -91,16 +129,43 @@ jobs: - name: Verify release metadata and derive the npm dist-tag id: version env: - RELEASE_IS_PRERELEASE: ${{ github.event.release.prerelease }} - RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TAG: ${{ steps.trust.outputs.release-tag }} shell: bash run: | set -euo pipefail node scripts/validate-release.mjs \ --tag "$RELEASE_TAG" \ - --release-prerelease "$RELEASE_IS_PRERELEASE" \ --require-final \ --require-releasable-docs >> "$GITHUB_OUTPUT" + - name: Verify the exact immutable GitHub release and tag + id: release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_COMMIT: ${{ steps.trust.outputs.release-commit }} + RELEASE_HTML_URL: ${{ steps.trust.outputs.release-url }} + RELEASE_TAG: ${{ steps.trust.outputs.release-tag }} + shell: bash + run: | + set -euo pipefail + release_json="$RUNNER_TEMP/github-release.json" + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" > "$release_json" + git fetch --no-tags origin \ + "+refs/tags/${RELEASE_TAG}:refs/tags/${RELEASE_TAG}" + tag_commit="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")" + + RELEASE_JSON="$release_json" TAG_COMMIT="$tag_commit" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validateGitHubRelease } from "./scripts/release-workflow-validation.mjs"; + + const release = JSON.parse(readFileSync(process.env.RELEASE_JSON, "utf8")); + validateGitHubRelease(release, { + htmlUrl: process.env.RELEASE_HTML_URL, + releaseCommit: process.env.RELEASE_COMMIT, + tag: process.env.RELEASE_TAG, + tagCommit: process.env.TAG_COMMIT, + }); + EOF - name: Use a Trusted Publishing-capable npm CLI run: npm install --global npm@11.12.1 - name: Install locked dependencies @@ -134,7 +199,7 @@ jobs: run: | npm run test:package -- \ --tarball "${{ steps.pack.outputs.tarball }}" \ - --tag "${{ github.event.release.tag_name }}" + --tag "${{ steps.trust.outputs.release-tag }}" npm run test:examples -- --tarball "${{ steps.pack.outputs.tarball }}" npm run test:fixtures -- --tarball "${{ steps.pack.outputs.tarball }}" - name: Upload the verified release artifact diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 0c7ea1e..308b8b5 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -4,6 +4,7 @@ on: push: branches: - main + workflow_dispatch: permissions: contents: read @@ -14,17 +15,311 @@ concurrency: jobs: release-please: - name: Prepare a reviewed release pull request + name: Prepare a reviewed release pull request or GitHub release if: vars.RELEASE_PLEASE_ENABLED == 'true' runs-on: ubuntu-latest timeout-minutes: 10 permissions: contents: write + issues: write pull-requests: write steps: + - name: Check out the current main branch + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + - name: Require the exact current main commit + env: + EXPECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + if [[ "$(git rev-parse HEAD)" != "$EXPECTED_SHA" ]]; then + echo "The checked-out commit does not match the triggering SHA." >&2 + exit 1 + fi + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$EXPECTED_SHA" ]]; then + echo "main moved after this Release Please run was triggered." >&2 + exit 1 + fi + - name: Reject rerun attempts + env: + RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash + run: | + set -euo pipefail + if [[ "$RUN_ATTEMPT" != "1" ]]; then + echo "Release Please reruns are forbidden; start a new first-attempt run." >&2 + exit 1 + fi + - name: Reject an unrelated stale Release Please branch + env: + EXPECTED_OWNER: cometapi-dev + RELEASE_BRANCH: release-please--branches--main--components--cometapi + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + branch_exists="false" + is_ancestor="false" + branch_sha="" + branch_version="" + manifest_version="" + main_version="$(node --print 'require("./package.json").version')" + pull_requests_file="$RUNNER_TEMP/release-please-pulls.json" + printf '[]\n' > "$pull_requests_file" + remote_ref="refs/heads/${RELEASE_BRANCH}" + if git ls-remote --exit-code --heads origin "$remote_ref" >/dev/null; then + branch_exists="true" + git fetch --no-tags origin \ + "+${remote_ref}:refs/remotes/origin/${RELEASE_BRANCH}" + release_ref="refs/remotes/origin/${RELEASE_BRANCH}" + branch_sha="$(git rev-parse "$release_ref")" + branch_version="$(git show "$release_ref:package.json" | node -e \ + 'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value).version))')" + manifest_version="$(git show "$release_ref:.release-please-manifest.json" | node -e \ + 'let value="";process.stdin.on("data",chunk=>value+=chunk).on("end",()=>process.stdout.write(JSON.parse(value)["."]))')" + if git merge-base --is-ancestor "$release_ref" refs/remotes/origin/main; then + is_ancestor="true" + fi + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls?state=all&head=${EXPECTED_OWNER}%3A${RELEASE_BRANCH}&per_page=100" \ + | jq 'add' > "$pull_requests_file" + fi + + BRANCH_EXISTS="$branch_exists" BRANCH_SHA="$branch_sha" \ + BRANCH_VERSION="$branch_version" IS_ANCESTOR="$is_ancestor" \ + MAIN_VERSION="$main_version" MANIFEST_VERSION="$manifest_version" \ + PULL_REQUESTS_FILE="$pull_requests_file" \ + node --input-type=module <<'EOF' + import { spawnSync } from "node:child_process"; + import { readFileSync } from "node:fs"; + import { validateReleasePleaseBranchState } from "./scripts/release-workflow-validation.mjs"; + + const branchSha = process.env.BRANCH_SHA; + const pullRequests = JSON.parse( + readFileSync(process.env.PULL_REQUESTS_FILE, "utf8"), + ) + .filter( + (pullRequest) => + pullRequest.base?.ref === "main" && + pullRequest.head?.ref === process.env.RELEASE_BRANCH && + pullRequest.head?.sha === branchSha, + ) + .map((pullRequest) => ({ + author: pullRequest.user?.login, + baseRef: pullRequest.base?.ref, + headRef: pullRequest.head?.ref, + headSha: pullRequest.head?.sha, + labels: pullRequest.labels?.map((label) => label.name), + mergeCommitIsAncestor: + typeof pullRequest.merge_commit_sha === "string" && + /^[0-9a-f]{40}$/.test(pullRequest.merge_commit_sha) && + spawnSync( + "git", + [ + "merge-base", + "--is-ancestor", + pullRequest.merge_commit_sha, + "refs/remotes/origin/main", + ], + { stdio: "ignore" }, + ).status === 0, + mergeCommitSha: pullRequest.merge_commit_sha, + mergedAt: pullRequest.merged_at, + number: pullRequest.number, + state: pullRequest.state, + title: pullRequest.title, + })); + + validateReleasePleaseBranchState({ + branchSha, + branchVersion: process.env.BRANCH_VERSION, + exists: process.env.BRANCH_EXISTS === "true", + isAncestor: process.env.IS_ANCESTOR === "true", + mainVersion: process.env.MAIN_VERSION, + manifestVersion: process.env.MANIFEST_VERSION, + pullRequests, + releaseBranch: process.env.RELEASE_BRANCH, + }); + EOF + - name: Require human-owner review on a merged release PR + env: + EVENT_NAME: ${{ github.event_name }} + GH_TOKEN: ${{ github.token }} + RELEASE_BRANCH: release-please--branches--main--components--cometapi + shell: bash + run: | + set -euo pipefail + release_pulls_file="$RUNNER_TEMP/release-pulls.json" + reviews_file="$RUNNER_TEMP/release-reviews.json" + permissions_file="$RUNNER_TEMP/reviewer-permissions.json" + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls?state=closed&head=cometapi-dev%3A${RELEASE_BRANCH}&per_page=100" \ + | jq 'add' > "$release_pulls_file" + release_pr_number="$(RELEASE_PULLS_FILE="$release_pulls_file" node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { selectPendingReleasePullRequest } from "./scripts/release-workflow-validation.mjs"; + + const pulls = JSON.parse( + readFileSync(process.env.RELEASE_PULLS_FILE, "utf8"), + ).map((pullRequest) => ({ + baseRef: pullRequest.base?.ref, + headRef: pullRequest.head?.ref, + labels: pullRequest.labels?.map((label) => label.name), + mergeCommitSha: pullRequest.merge_commit_sha, + mergedAt: pullRequest.merged_at, + number: pullRequest.number, + state: pullRequest.state, + })); + const releasePullRequest = selectPendingReleasePullRequest(pulls, { + eventName: process.env.EVENT_NAME, + releaseBranch: process.env.RELEASE_BRANCH, + releaseCommit: process.env.GITHUB_SHA, + }); + process.stdout.write( + releasePullRequest === null ? "" : String(releasePullRequest.number), + ); + EOF + )" + if [[ -z "$release_pr_number" ]]; then + echo "This is a release-PR preparation run; no merged release PR is associated with HEAD." + exit 0 + fi + + gh api --paginate --slurp \ + "repos/${GITHUB_REPOSITORY}/pulls/${release_pr_number}/reviews?per_page=100" \ + | jq 'add' > "$reviews_file" + printf '{}\n' > "$permissions_file" + while IFS= read -r reviewer; do + permission="$(gh api \ + "repos/${GITHUB_REPOSITORY}/collaborators/${reviewer}/permission" \ + --jq '.permission' 2>/dev/null || printf 'none')" + next_permissions="$RUNNER_TEMP/reviewer-permissions-next.json" + jq --arg reviewer "$reviewer" --arg permission "$permission" \ + '. + {($reviewer): $permission}' \ + "$permissions_file" > "$next_permissions" + mv "$next_permissions" "$permissions_file" + done < <(jq -r '.[].user.login' "$reviews_file" | sort -u) + + PERMISSIONS_FILE="$permissions_file" RELEASE_PULLS_FILE="$release_pulls_file" \ + RELEASE_PR_NUMBER="$release_pr_number" REVIEWS_FILE="$reviews_file" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validateMergedReleasePullRequest } from "./scripts/release-workflow-validation.mjs"; + + const pulls = JSON.parse(readFileSync(process.env.RELEASE_PULLS_FILE, "utf8")); + const rawPullRequest = pulls.find( + (pullRequest) => pullRequest.number === Number(process.env.RELEASE_PR_NUMBER), + ); + const permissions = JSON.parse( + readFileSync(process.env.PERMISSIONS_FILE, "utf8"), + ); + const reviews = JSON.parse(readFileSync(process.env.REVIEWS_FILE, "utf8")); + const version = JSON.parse(readFileSync("package.json", "utf8")).version; + validateMergedReleasePullRequest({ + pullRequest: { + author: rawPullRequest.user?.login, + baseRef: rawPullRequest.base?.ref, + headRef: rawPullRequest.head?.ref, + headSha: rawPullRequest.head?.sha, + labels: rawPullRequest.labels?.map((label) => label.name), + mergeCommitSha: rawPullRequest.merge_commit_sha, + mergedAt: rawPullRequest.merged_at, + number: rawPullRequest.number, + state: rawPullRequest.state, + title: rawPullRequest.title, + }, + releaseBranch: process.env.RELEASE_BRANCH, + releaseCommit: process.env.GITHUB_SHA, + reviews: reviews.map((review) => ({ + commitId: review.commit_id, + id: review.id, + login: review.user?.login, + permission: permissions[review.user?.login] ?? "none", + state: review.state, + userType: review.user?.type, + })), + version, + }); + EOF + - name: Reconfirm main before Release Please mutation + env: + EXPECTED_SHA: ${{ github.sha }} + shell: bash + run: | + set -euo pipefail + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$EXPECTED_SHA" ]]; then + echo "main moved during Release Please preflight." >&2 + exit 1 + fi - name: Run Release Please + id: release uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 with: config-file: release-please-config.json manifest-file: .release-please-manifest.json - skip-github-release: true + - name: Record the exact Release Please release result + env: + RELEASE_CREATED: ${{ steps.release.outputs.release_created }} + RELEASE_HTML_URL: ${{ steps.release.outputs.html_url }} + RELEASE_SHA: ${{ steps.release.outputs.sha }} + RELEASE_TAG_NAME: ${{ steps.release.outputs.tag_name }} + RELEASE_VERSION: ${{ steps.release.outputs.version }} + RELEASES_CREATED: ${{ steps.release.outputs.releases_created }} + RELEASED_PATHS: ${{ steps.release.outputs.paths_released }} + shell: bash + run: | + set -euo pipefail + mkdir -p release-please-result + node --input-type=module <<'EOF' + import { readFileSync, writeFileSync } from "node:fs"; + import { validateReleasePleaseActionResult } from "./scripts/release-workflow-validation.mjs"; + + if (process.env.RELEASES_CREATED !== "true") { + throw new Error("Release Please did not create exactly one release."); + } + const releasedPaths = JSON.parse(process.env.RELEASED_PATHS); + if (releasedPaths.length !== 1 || releasedPaths[0] !== ".") { + throw new Error("Release Please did not release exactly the root package."); + } + const version = JSON.parse(readFileSync("package.json", "utf8")).version; + const result = { + htmlUrl: process.env.RELEASE_HTML_URL, + releaseCreated: process.env.RELEASE_CREATED === "true", + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), + runId: Number(process.env.GITHUB_RUN_ID), + schemaVersion: 1, + sha: process.env.RELEASE_SHA, + tagName: process.env.RELEASE_TAG_NAME, + version: process.env.RELEASE_VERSION, + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }; + validateReleasePleaseActionResult(result, { + releaseCommit: process.env.GITHUB_SHA, + repository: process.env.GITHUB_REPOSITORY, + runAttempt: Number(process.env.GITHUB_RUN_ATTEMPT), + runId: Number(process.env.GITHUB_RUN_ID), + version, + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }); + writeFileSync( + "release-please-result/result.json", + `${JSON.stringify(result)}\n`, + { mode: 0o600 }, + ); + EOF + - name: Upload the exact Release Please result + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: release-please-result-${{ github.run_id }}-${{ github.run_attempt }} + path: release-please-result/result.json + if-no-files-found: error + retention-days: 30 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0cf5354..6ef7588 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,12 +27,33 @@ only for CometAPI defaults and public branding: 1. An explicit constructor `apiKey` wins over `COMETAPI_KEY`. 2. An explicit constructor `baseURL` wins over `COMETAPI_BASE_URL`. 3. The default base URL is `https://api.cometapi.com/v1`. -4. Other documented OpenAI client options pass through unchanged. +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 inherited `withOptions` path is constrained to the same +`CometAPIOptions` contract. Both the constructor and `withOptions` validate +runtime objects before delegating upstream so plain JavaScript and type casts +cannot restore a forbidden routing, authentication, or browser bypass. A +forbidden field is rejected when its value is not `undefined`; the error names +the field but never serializes its value. Missing or blank CometAPI credentials and blank explicit base URLs are rejected before transport through the official `OpenAIError` family. Configuration validation must not introduce an unrelated SDK-specific error hierarchy. +Options such as `timeout`, `maxRetries`, `fetch`, `fetchOptions`, +`defaultHeaders`, `defaultQuery`, `logger`, `organization`, `project`, +`webhookSecret`, and `adminAPIKey` remain pass-through configuration. This +restriction does not expand the 0.1 resource surface. + The official dependency owns HTTP transport, request and response models, errors, retries, timeouts, pagination, streaming parsing, stream lifecycle, and custom `fetch` integration. This repository must not reimplement those layers @@ -91,6 +112,37 @@ Trusted Publishing remains the default authentication path. The only token path is an explicitly enabled protected-environment fallback that rejects every version except `0.1.0-alpha.1` and every dist-tag except `next`. +For normal stable patches, Release Please owns the reviewed version/changelog +PR and the immutable tag and GitHub Release. The configuration uses an explicit +`cometapi` component and stable versioning so a root package does not fall into +the single-package tag-discovery ambiguity encountered during 0.1.0. Because a +GitHub Release created with the default `GITHUB_TOKEN` does not start a separate +`release.published` workflow, publication is chained from the successful +Release Please workflow. The handoff accepts only the canonical repository's +successful first-attempt `push` run for `main` at the still-current exact `main` +SHA. The release workflow records `release_created`, SHA, tag, version, URL, +repository, workflow identity, run ID, and attempt in an exact-run artifact. +Publication downloads and validates that artifact before checking the tag and +immutable Release. Runs that fail while preparing a pull request are filtered +out; any successful run without the exact Release Please-created result, tag, +and immutable Release fails before live or registry access. The release outcome +and package artifact are verified independently. + +Release Please and publication remain separate trust domains. Release Please +does not receive npm OIDC permission; `id-token: write` remains limited to the +protected publish job. Repository variables gate both flows, and reruns remain +fail-closed on exact tag, artifact, dist-tag, integrity, and provenance state. +Release Please itself rejects attempt 2 or later before repository mutation; +the explicitly enabled preparation path uses a new manual dispatch, while only +a new `push` run can enter publication. A merged release PR is accepted for +tagging only after a distinct repository administrator approved its final head. +The workflow checks the triggering SHA against the fetched `main` tip both at +checkout and immediately before Release Please mutation, so an older queued run +cannot release a newer default-branch commit. +It also rejects any pending merged release PR whose merge commit is not the +current push SHA. Manual dispatch is therefore release-inert: it may prepare a +branch only when no merged release PR is awaiting a tag. + ## Testing layers Evidence is separated by layer: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bfc912..ae677d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,13 +9,6 @@ follows Keep a Changelog, and versions follow Semantic Versioning. - prepare 0.1.0 stable release ([#27](https://github.com/cometapi-dev/cometapi-node/issues/27)) ([f5f6731](https://github.com/cometapi-dev/cometapi-node/commit/f5f6731ba9a5bb0fbfdc1ed256c3e66e3c03ca96)) -## [Unreleased] - -### Documentation - -- Recorded the verified stable `0.1.0` release evidence and the deferred Release - Please automation follow-up. - ## [0.1.0-alpha.3] - 2026-07-27 ### Fixed diff --git a/COMPATIBILITY.md b/COMPATIBILITY.md index 0084ce3..50e63e0 100644 --- a/COMPATIBILITY.md +++ b/COMPATIBILITY.md @@ -6,6 +6,9 @@ Package line: `0.1.x` Stable release: `0.1.0`; the immutable release workflow and separate post-publication registry verification completed on 2026-07-28. +Maintenance candidate: `0.1.1`; the options-contract and Release Please repair +is not a release claim until its remote release and registry evidence completes. + This matrix defines the contract-tested 0.1 compatibility surface. Inheritance from the official OpenAI client does not by itself establish CometAPI support. Release and live-compatibility claims require their corresponding CI, registry, @@ -37,6 +40,29 @@ as official `OpenAIError` instances. HTTP responses continue to use the more specific official `APIError` subclasses. This distinction is part of the tested error contract. +## Client options 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: + +| 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` 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. + ## Inherited but unsupported in 0.1 The following categories may be reachable through the upstream `OpenAI` class, diff --git a/README.md b/README.md index 6046287..70f4d4c 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,11 @@ defaulting the client to CometAPI. > is complete, and the package is available from npm's default `latest` dist-tag. > The supported API is limited to the contract-tested 0.1 surface documented > here and in [COMPATIBILITY.md](./COMPATIBILITY.md). +> +> A `0.1.1` maintenance release is being prepared to correct the public options +> contract and restore the normal Release Please path. It is not released until +> the immutable release, npm OIDC publication, and public-registry verification +> complete. ## Supported 0.1 surface @@ -162,6 +167,21 @@ 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 +`withOptions` calls. A rejection is an official OpenAI `OpenAIError` and names +only the forbidden field; it never includes the supplied value. + +Supported options remain available, including `timeout`, `maxRetries`, `fetch`, +`fetchOptions`, `defaultHeaders`, `defaultQuery`, `logger`, `organization`, +`project`, `webhookSecret`, `adminAPIKey`, and per-request options. The client +continues to derive its CometAPI API key and base URL from the documented +constructor and environment settings. + ## Direct OpenAI client interoperability Applications may also configure the official client directly. This is an @@ -226,6 +246,10 @@ layers and must not be represented as another. Because published npm artifacts are immutable, the `0.1.0` tarball retains its candidate-era README; this post-release status update first ships in a later package version. +The `0.1.1` options-contract and Release Please repair is in progress. Until its +full release sequence completes, npm `latest` remains `0.1.0` and this candidate +must not be described as published. No 0.2 provider adapter work is included. + See: - [Canonical repository](https://github.com/cometapi-dev/cometapi-node) diff --git a/RELEASING.md b/RELEASING.md index f80da77..058e54f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -12,6 +12,8 @@ Release status is evidence-based: | Registry Alpha candidate | The exact `0.1.0-alpha.3` artifact passes package and clean-install gates after preserving the unpublished immutable alpha.2 failure record. | | Registry Alpha released | The public npm artifact installs from the `next` channel, passes post-publication verification, and has verified provenance plus any documented one-time bootstrap evidence. | | Stable released | Every stable 0.1.0 local, remote, live, review, provenance, and registry gate has recorded evidence. | +| Stable patch candidate | A maintainer-created Release Please PR has the exact version, changelog, manifest, temporary-anchor removal, complete CI matrix, and human-owner review. | +| Stable patch released | The immutable Release Please tag and GitHub Release, bounded live smoke, npm OIDC publication, and independent public-registry verification all pass. | A build, mock, valid workflow file, successful upload, or HTTP 200 proves only its own layer. Never use it to claim a later state. @@ -230,25 +232,44 @@ The repository maintains four independently auditable workflows: first failure. Standalone and release smoke jobs share one repository-wide 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 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 - are rejected. The workflow verifies the dist-tag, integrity, provenance - attestation, signatures, deduplication, and public installation. A rerun - resumes after an already accepted version only when its registry integrity - matches the downloaded artifact, then repeats all bounded registry-state and - signature checks. +- `release-please.yml`: stable versioning, an explicit `cometapi` component, + separate pull requests, and an explicit component/version title. It requires + `RELEASE_PLEASE_ENABLED=true`, accepts only first-attempt runs, and uses the + default `GITHUB_TOKEN`. A first-attempt manual dispatch prepares the patch + branch after the variable is enabled; rerunning an older workflow is + rejected before Release Please can mutate repository state. The + repository deliberately leaves Actions pull-request authorization disabled, + so a maintainer creates the standard PR from the generated branch, applies + the `autorelease: pending` label, and obtains another administrator's approval + on the final head. The post-merge workflow verifies that exact approval even + when the PR used a squash or rebase merge. Release Please then creates the + normal tag and GitHub Release, transitions the release label, and uploads its + exact release outputs as a run-bound artifact. + The triggering SHA must still equal the fetched `main` tip at checkout and + immediately before the Release Please action; an older queued run stops before + mutation. + This flow does not introduce a PAT or GitHub App credential. + Component identity remains internal to release discovery: + `include-component-in-tag=false` requires the exact public `v` tag. + The workflow has contents, pull-request, and issue permissions only for those + repository operations; it has no npm OIDC permission. +- `publish.yml`: starts only after successful completion of the trusted Release + Please workflow for `main`. This indirection is required because a GitHub + Release created with the default `GITHUB_TOKEN` does not trigger a new + `release.published` workflow. The handoff accepts only the canonical + repository's successful, first-attempt `push` run for the still-current exact + `main` SHA. It downloads the output artifact from that exact upstream run and + requires `release_created`, SHA, tag, version, URL, repository, workflow path, + run ID, and attempt to agree before accepting the matching Release + Please-created version tag and immutable GitHub Release. Failed pull-request + preparation runs are filtered out; a successful run without that exact result + fails before live or publication work. The release path then packs and tests one exact artifact, runs the + protected release live smoke, and publishes the same file through npm OIDC. Registry token + credentials are rejected. The workflow verifies the dist-tag, integrity, + provenance attestation, signatures, deduplication, and public installation. A + publish rerun resumes after an already accepted version only when its registry + integrity matches the downloaded artifact, then repeats every bounded + registry-state and signature check. Third-party actions are pinned to full commit SHAs. Workflow permissions remain read-only except where a documented job requires more; `id-token: write` belongs @@ -397,13 +418,14 @@ layers: `latest` to `0.1.0`; the historical registry-created `latest` value on `0.1.0-alpha.1` no longer remains. -## Stable 0.1.0 sequence +## Stable 0.1.x sequence ```text feature or fix pull request -> required offline CI -> merge to the protected default branch - -> automated release PR + -> generated Release Please branch + -> maintainer-created release PR -> human review and merge -> immutable tag and GitHub release -> rebuild and verify exact artifact @@ -418,11 +440,58 @@ executed README examples against the packed artifact, release-PR/tag/changelog/ manifest version agreement, reviewed security and compatibility status, and post-publication registry evidence. -The `0.1.0` promotion limited Release Please 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 release PR merged, a maintainer created the -draft `v0.1.0` GitHub Release manually against the exact merge commit, reviewed -it with `prerelease=false`, and published it with immutable releases enabled. +The 0.1.1 repair uses one explicit `last-release-sha` boundary at the immutable +0.1.0 release commit, `1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca`. It exists +only to prevent pre-0.1.0 features from being rediscovered while the normal +Release Please history is repaired. The generated 0.1.1 PR must remove that +temporary override before merge; future releases must discover the Release +Please-created `v0.1.1` boundary normally. + +Before enabling the repaired workflow, create the standard +`autorelease: pending` and `autorelease: tagged` labels if they are still +absent. The configuration names both labels explicitly. Because Actions +pull-request creation remains disabled, the maintainer-created release PR must +receive `autorelease: pending` before merge so Release Please can discover it +and perform the normal tagged transition with its scoped `issues: write` +permission. + +After enabling `RELEASE_PLEASE_ENABLED`, start a new manual dispatch on `main`; +do not rerun the skipped workflow from the repair merge. Only attempt 1 may call +Release Please. The manually dispatched preparation run cannot trigger npm +publication or create a Release: it is accepted only when no merged +`autorelease: pending` PR exists, and `publish.yml` accepts only an upstream +`push` event. The release-PR merge creates the new first-attempt `push` run that +may tag and publish. A later push cannot tag an older outstanding release PR; +its merge SHA must equal the triggering SHA before Release Please runs. + +The stale branch +`release-please--branches--main--components--cometapi` at +`3f0949e5c0ccd0923d10595437f7a315f013af7c` is the failed run's evidence, not a +release candidate. Immediately before replacing or deleting it, confirm that +it still contains the documented generated 0.2.0 state, has no associated open +PR, and contains no independent work. Do not delete or rewrite any other +branch. + +For 0.1.1, a normal `fix:` commit after 0.1.0 must produce exactly one patch PR. +Stop if the branch contains 0.2.0, if any version/manifest/changelog value is not +0.1.1, or if the generated PR is not attributable to the explicit `cometapi` +component. Merge is forbidden until Node.js 22 and 24 blocking checks, the +Node.js 26 advisory lane, minimum/locked/latest OpenAI 6.x compatibility, +package and declaration checks, and human-owner review complete on the final +head. After registry verification, restore `RELEASE_PLEASE_ENABLED=false` and +keep the already enabled scheduled-smoke policy at `LIVE_SMOKE_ENABLED=true`. +Use a separate post-release documentation PR to record the ROADMAP and +RELEASING evidence; only that verified closeout may mark Repository foundation +Complete. + +The `0.1.0` promotion limited Release Please to the stable PR because the +pinned v5.0.0 action bundles Release Please 17.6.0 and its single-package path +has the open upstream +[release-please-action issue #1205](https://github.com/googleapis/release-please-action/issues/1205) +when component names are omitted from tags. After the release PR merged, a +maintainer created the draft `v0.1.0` GitHub Release manually against the exact +merge commit, reviewed it with `prerelease=false`, and published it with +immutable releases enabled. Release Please did not add an `autorelease: pending` label to the manually opened stable PR, and the repository has no `autorelease` labels, so no post-tag label transition applied to this release. @@ -432,6 +501,20 @@ the focused README, SECURITY, SUPPORT, COMPATIBILITY, and ROADMAP candidate state to the generated branch and repeated the CI review on the final head before merging it. +The failed post-merge Release Please +[run 30345116433](https://github.com/cometapi-dev/cometapi-node/actions/runs/30345116433) +started seven seconds after the maintainer created a draft `v0.1.0` Release, but +the draft had no tag and was not published until more than eight minutes after +the run failed. Release Please therefore found no discoverable published +release/tag boundary, scanned the older initial feature commit, and prepared an +unrequested 0.2.0 branch update. PR creation then failed for the separate reason +that repository Actions are not authorized to create or approve pull requests. +Publishing the manual Release later could not retroactively bound that run, and +leaving `skip-github-release` enabled would continue the split discovery model. +The 0.1.1 repair replaces that historical combination with explicit component +identity, one-cycle history anchoring, maintainer-created/human-reviewed release +PRs, and normal Release Please tag and GitHub Release creation. + ## Stable 0.1.0 release evidence Stable `0.1.0` completed on 2026-07-28 with these independently auditable diff --git a/ROADMAP.md b/ROADMAP.md index f5e3bb3..87763d7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,7 +1,7 @@ # CometAPI TypeScript and Node.js SDK Roadmap -Status: Public Preview, Registry Alpha, and 0.1.0 stable complete -Last updated: 2026-07-28 +Status: 0.1.1 options-contract and Release Please maintenance in progress +Last updated: 2026-07-29 Repository contract: This roadmap is self-contained and is the public source of truth for this repository's release sequence. @@ -35,6 +35,7 @@ default `latest` channel with verified provenance and public-install evidence. | 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 | Complete | Users can install a fully verified package from npm's default channel. | +| 0.1.1 maintenance patch | In progress | Users receive the corrected public options contract through the repaired, reviewed normal release path. | | 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. | @@ -94,12 +95,38 @@ URL. The canonical repository is and `https://github.com/cometapi-dev/cometapi-node/issues` for `bugs.url`. `CODEOWNERS` remains absent until a real multi-maintainer model exists. -Foundation remains in progress after stable `0.1.0` because Release Please is -disabled pending a separate review of its post-manual-release discovery and -pull-request authorization strategy. Its failed post-merge run generated an -unreviewed temporary `0.2.0` branch commit but did not create a pull request or -change `main`; that branch is failure evidence only and is not the start of the -0.2 milestone. +Foundation remains in progress after stable `0.1.0`. The post-merge Release +Please [run 30345116433](https://github.com/cometapi-dev/cometapi-node/actions/runs/30345116433) +ran after a draft `v0.1.0` Release had been created but before that draft was +published or had a tag. With no discoverable Release Please release boundary, +it scanned older Conventional Commits, including the initial feature, and +generated an unrequested `0.2.0` temporary branch commit. Pull-request creation +then failed independently because the repository does not authorize GitHub +Actions to create or approve pull requests. The run did not change `main`, +create a tag or published Release, or publish to npm. + +The 0.1.1 maintenance work makes the component and stable-patch policy +explicit, anchors the one repair cycle at the exact 0.1.0 release commit, and +restores Release Please ownership of the reviewed version/changelog PR plus the +immutable tag and GitHub Release. The repository's Actions PR authorization is +not broadened: a maintainer creates the PR from the generated branch and a +human owner reviews it. The temporary anchor must be removed in that release PR +before merge. The explicit component does not enter the public tag; the only +accepted patch tag is `v0.1.1`. Publication is triggered from the successful +first-attempt Release Please push run and independently verifies that run's +exact release-created output artifact, default-branch commit, tag, immutable +Release, and package artifact before the existing bounded live smoke and npm +OIDC steps. The post-merge run also requires an administrator's approval on the +release PR's final head. + +The exact stale branch remains failure evidence until its contents, lack of an +open PR, and lack of independent work are reconfirmed immediately before +cleanup. It must never be merged or treated as the start of 0.2. Repository +foundation may become Complete only after the real 0.1.1 release flow, public +registry installation, and a separate post-release documentation PR recording +ROADMAP and RELEASING evidence succeed. Until then, +`RELEASE_PLEASE_ENABLED` remains a temporary release-operation control, +`latest` remains `0.1.0`, and `next` remains `0.1.0-alpha.3`. ## Private Remote Validation @@ -237,6 +264,41 @@ Explicit non-goals: - Provider-neutral message translation. - Image, video, audio, batch, fine-tuning, and realtime APIs. +## 0.1.1: Options Contract and Release Repair + +This maintenance patch 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. + +The patch also restores a normal stable Release Please path with explicit +component identity, patch versioning, pull-request configuration, one-cycle +0.1.0 history anchoring, normal `v0.1.1` tag and immutable GitHub Release +creation, and a trusted `workflow_run` handoff to the existing exact-artifact, +bounded-live, and npm OIDC gates. Regression tests must reject stale manifest +state, a 0.2 bump, missing PR configuration, an unrelated stale branch, hostile +workflow events, reruns, mismatched action outputs, missing final-head approval, +and declaration or runtime option bypasses. + +Exit criteria: + +- 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. +- The repair PR and generated 0.1.1 release PR pass required CI on their exact + final heads, and the release PR receives human-owner review. +- Release Please creates the exact immutable `v0.1.1` Release; the existing + three-request live smoke and npm OIDC publication pass without changing + `next=0.1.0-alpha.3`. +- A public-registry install verifies ESM, CommonJS, declarations, supported + mocked calls, one effective OpenAI installation, API error identity, + integrity, signatures, and provenance. +- After publication, maintainers restore `RELEASE_PLEASE_ENABLED=false` while + keeping `LIVE_SMOKE_ENABLED=true`. A separate post-release documentation PR + records the evidence and only then marks Repository foundation Complete. + ## 0.2.0: Provider-Native Text Adapters Planned scope: @@ -297,8 +359,10 @@ The repository will maintain: branch to the pinned CometAPI HTTPS endpoint. Each run is fixed at exactly three sequential requests, a 16-token output cap, a 60-second per-request timeout, concurrency one, and stop on the first failure. -- `release-please.yml` for the reviewed version and changelog PR. -- `publish.yml` for immutable-release and `main`-ancestry enforcement, exact +- `release-please.yml` for the reviewed version and changelog PR and the + corresponding immutable tag and GitHub Release. +- `publish.yml` for trusted Release Please completion, immutable-release and + `main`-ancestry enforcement, exact tarball verification, a protected release-tag live smoke, npm OIDC publication, and dist-tag, integrity, provenance, signature, and registry verification. @@ -330,15 +394,23 @@ 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. +The manual tag/Release combination used for 0.1.0 is historical evidence, not +the normal patch process. Stable 0.1.x patches require explicit stable +versioning and the `cometapi` component, a maintainer-created and human-reviewed +Release Please PR, and automated immutable tag and GitHub Release creation after +merge. Because the default `GITHUB_TOKEN` cannot cause a second workflow through +a `release.published` event, `publish.yml` starts from successful Release Please +workflow completion and re-establishes trust from exact repository state. +Failed pull-request preparation runs are filtered out; any successful run +without a run-bound `release_created` result, the exact Release Please-created +tag, and immutable Release fails before live or registry access. Only a +successful canonical first-attempt `push` run for the still-current exact +`main` SHA can enter artifact verification. 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 -bootstrap below. Stable 0.1.0 requires the release-please flow, full +bootstrap below. Stable 0.1.x requires the Release Please flow, full supported-runtime matrix, version and changelog agreement, package-shape checks, provenance, executed README examples, and post-publication install verification. diff --git a/fixtures/openai-host/consumer.cts b/fixtures/openai-host/consumer.cts index 931eed5..974bc8f 100644 --- a/fixtures/openai-host/consumer.cts +++ b/fixtures/openai-host/consumer.cts @@ -1,5 +1,5 @@ import { CometAPI, type CometAPIOptions } from "cometapi"; -import { APIPromise, OpenAI, PagePromise } from "openai"; +import { APIPromise, type ClientOptions, OpenAI, PagePromise } from "openai"; import type { ChatCompletion, ChatCompletionChunk, @@ -18,6 +18,32 @@ const options: CometAPIOptions = { const client = new CometAPI(options); const upstream: OpenAI = client; +const unsupportedProviderOptions: CometAPIOptions = { + // @ts-expect-error CometAPI does not expose OpenAI provider routing. + provider: {} as NonNullable, +}; +const unsupportedWorkloadIdentityOptions: CometAPIOptions = { + // @ts-expect-error CometAPI owns API-key authentication. + workloadIdentity: {} as NonNullable, +}; +const unsupportedBrowserOptions: CometAPIOptions = { + // @ts-expect-error Browser-side long-lived key use is unsupported. + dangerouslyAllowBrowser: true, +}; + +client.withOptions({ + // @ts-expect-error withOptions must not expose OpenAI provider routing. + provider: {} as NonNullable, +}); +client.withOptions({ + // @ts-expect-error withOptions must not expose workload identity authentication. + workloadIdentity: {} as NonNullable, +}); +client.withOptions({ + // @ts-expect-error withOptions must not expose the browser safety bypass. + dangerouslyAllowBrowser: true, +}); + const chat: APIPromise = client.chat.completions.create({ messages: [{ content: "Reply with OK.", role: "user" }], model: "gpt-5.4", @@ -40,4 +66,14 @@ const responseStream: APIPromise> = }); const models: PagePromise = client.models.list(); -void [chat, chatStream, models, response, responseStream, upstream]; +void [ + chat, + chatStream, + models, + response, + responseStream, + unsupportedBrowserOptions, + unsupportedProviderOptions, + unsupportedWorkloadIdentityOptions, + upstream, +]; diff --git a/fixtures/openai-host/consumer.mts b/fixtures/openai-host/consumer.mts index 931eed5..974bc8f 100644 --- a/fixtures/openai-host/consumer.mts +++ b/fixtures/openai-host/consumer.mts @@ -1,5 +1,5 @@ import { CometAPI, type CometAPIOptions } from "cometapi"; -import { APIPromise, OpenAI, PagePromise } from "openai"; +import { APIPromise, type ClientOptions, OpenAI, PagePromise } from "openai"; import type { ChatCompletion, ChatCompletionChunk, @@ -18,6 +18,32 @@ const options: CometAPIOptions = { const client = new CometAPI(options); const upstream: OpenAI = client; +const unsupportedProviderOptions: CometAPIOptions = { + // @ts-expect-error CometAPI does not expose OpenAI provider routing. + provider: {} as NonNullable, +}; +const unsupportedWorkloadIdentityOptions: CometAPIOptions = { + // @ts-expect-error CometAPI owns API-key authentication. + workloadIdentity: {} as NonNullable, +}; +const unsupportedBrowserOptions: CometAPIOptions = { + // @ts-expect-error Browser-side long-lived key use is unsupported. + dangerouslyAllowBrowser: true, +}; + +client.withOptions({ + // @ts-expect-error withOptions must not expose OpenAI provider routing. + provider: {} as NonNullable, +}); +client.withOptions({ + // @ts-expect-error withOptions must not expose workload identity authentication. + workloadIdentity: {} as NonNullable, +}); +client.withOptions({ + // @ts-expect-error withOptions must not expose the browser safety bypass. + dangerouslyAllowBrowser: true, +}); + const chat: APIPromise = client.chat.completions.create({ messages: [{ content: "Reply with OK.", role: "user" }], model: "gpt-5.4", @@ -40,4 +66,14 @@ const responseStream: APIPromise> = }); const models: PagePromise = client.models.list(); -void [chat, chatStream, models, response, responseStream, upstream]; +void [ + chat, + chatStream, + models, + response, + responseStream, + unsupportedBrowserOptions, + unsupportedProviderOptions, + unsupportedWorkloadIdentityOptions, + upstream, +]; diff --git a/release-please-config.json b/release-please-config.json index 784f6f1..e989cde 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -1,15 +1,21 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "label": "autorelease: pending", + "last-release-sha": "1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca", + "release-label": "autorelease: tagged", + "separate-pull-requests": true, "packages": { ".": { "release-type": "node", - "versioning": "prerelease", + "versioning": "default", "prerelease": false, - "skip-github-release": true, + "component": "cometapi", + "skip-github-release": false, "changelog-path": "CHANGELOG.md", "include-component-in-tag": false, "include-v-in-tag": true, - "include-v-in-release-name": true + "include-v-in-release-name": true, + "pull-request-title-pattern": "chore${scope}: release${component} ${version}" } } } diff --git a/scripts/release-validation.mjs b/scripts/release-validation.mjs index b8f86aa..b236d97 100644 --- a/scripts/release-validation.mjs +++ b/scripts/release-validation.mjs @@ -863,17 +863,14 @@ function validateReleasePleaseState({ const manifestVersion = releaseManifest?.["."]; const hasBootstrapVersion = Object.hasOwn(packageConfig, "release-as"); const bootstrapVersion = packageConfig["release-as"]; + const stableBoundary = "1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca"; + const stableTitlePattern = "chore${scope}: release${component} ${version}"; requireExact( packageConfig["release-type"], "node", "Release Please release-type", ); - requireExact( - packageConfig.versioning, - "prerelease", - "Release Please versioning", - ); requireExact( packageConfig["changelog-path"], "CHANGELOG.md", @@ -900,6 +897,11 @@ function validateReleasePleaseState({ let releaseState; if (isPrerelease && packageConfig.prerelease === true) { + requireExact( + packageConfig.versioning, + "prerelease", + "Release Please versioning", + ); if (prerelease?.split(".")[0] !== "alpha") { throw new Error( "Release Please alpha-active state requires alpha prereleases.", @@ -912,6 +914,11 @@ function validateReleasePleaseState({ ); releaseState = "alpha-active"; } else if (isPrerelease && packageConfig.prerelease === false) { + requireExact( + packageConfig.versioning, + "prerelease", + "Release Please versioning", + ); 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.", @@ -932,9 +939,9 @@ function validateReleasePleaseState({ } releaseState = "stable-promotion"; } else if (!isPrerelease && packageConfig.prerelease === false) { - if (version !== "0.1.0") { + if (!/^0\.1\.(?:0|[1-9]\d*)$/.test(version)) { throw new Error( - "Release Please stable candidate must be exactly version 0.1.0.", + "Release Please stable maintenance must remain within 0.1.x.", ); } if (Object.hasOwn(packageConfig, "prerelease-type")) { @@ -943,14 +950,53 @@ function validateReleasePleaseState({ ); } requireExact( - packageConfig["skip-github-release"], + packageConfig.versioning, + "default", + "Release Please versioning", + ); + requireExact( + packageConfig.component, + "cometapi", + "Release Please component", + ); + requireExact( + releaseConfig["separate-pull-requests"], true, + "Release Please separate-pull-requests", + ); + requireExact( + releaseConfig.label, + "autorelease: pending", + "Release Please pending label", + ); + requireExact( + releaseConfig["release-label"], + "autorelease: tagged", + "Release Please release label", + ); + requireExact( + packageConfig["pull-request-title-pattern"], + stableTitlePattern, + "Release Please pull-request-title-pattern", + ); + requireExact( + packageConfig["skip-github-release"], + false, "Release Please skip-github-release", ); if (Object.hasOwn(packageConfig, "draft")) { throw new Error("Release Please stable candidate must remove draft."); } - releaseState = "stable-candidate"; + const lastReleaseSha = releaseConfig["last-release-sha"]; + if ( + lastReleaseSha !== undefined && + (version !== "0.1.0" || lastReleaseSha !== stableBoundary) + ) { + throw new Error( + "Release Please last-release-sha is allowed only as the exact one-cycle 0.1.0 boundary.", + ); + } + releaseState = "stable-maintenance"; } else { throw new Error( "Release Please prerelease settings do not match the package release channel.", @@ -980,6 +1026,11 @@ function validateReleasePleaseState({ "The one-time Release Please release-as setting must be removed before publication.", ); } + if (Object.hasOwn(releaseConfig, "last-release-sha")) { + throw new Error( + "The one-cycle Release Please last-release-sha must be removed before publication.", + ); + } } else if (manifestVersion !== version && bootstrapVersion !== version) { throw new Error( "Release Please must track the source version or explicitly bootstrap it with release-as.", diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs new file mode 100644 index 0000000..6930a9d --- /dev/null +++ b/scripts/release-workflow-validation.mjs @@ -0,0 +1,344 @@ +const STABLE_VERSION_PATTERN = /^0\.1\.(0|[1-9]\d*)$/; + +function fail(message) { + throw new Error(message); +} + +function requireEqual(actual, expected, label) { + if (actual !== expected) { + fail( + `Release workflow ${label} must equal ${String(expected)}; received ${String(actual)}.`, + ); + } +} + +function requireCommit(value, label) { + if (typeof value !== "string" || !/^[0-9a-f]{40}$/.test(value)) { + fail(`Release workflow ${label} must be a full lowercase commit SHA.`); + } +} + +function requirePositiveInteger(value, label) { + if (!Number.isInteger(value) || value < 1) { + fail(`Release workflow ${label} must be a positive integer.`); + } +} + +function stablePatch(version, label) { + const match = + typeof version === "string" && version.match(STABLE_VERSION_PATTERN); + if (!match) { + fail(`Release workflow ${label} must be a stable 0.1.x version.`); + } + return Number(match[1]); +} + +function releaseTitle(version) { + return `chore(main): release cometapi ${version}`; +} + +function requirePendingLabel(labels, label) { + if (!Array.isArray(labels) || !labels.includes("autorelease: pending")) { + fail(`Release workflow ${label} must have the autorelease: pending label.`); + } +} + +function requireReleasePullRequest( + pullRequest, + { branchSha, branchVersion, releaseBranch }, +) { + if (pullRequest === null || typeof pullRequest !== "object") { + fail("Release workflow release pull request metadata must be an object."); + } + requirePositiveInteger(pullRequest.number, "release pull request number"); + requireCommit(pullRequest.headSha, "release pull request head SHA"); + requireCommit(branchSha, "expected release pull request head SHA"); + requireEqual(pullRequest.baseRef, "main", "release pull request base"); + requireEqual(pullRequest.headRef, releaseBranch, "release pull request head"); + requireEqual(pullRequest.headSha, branchSha, "release pull request head SHA"); + requireEqual( + pullRequest.title, + releaseTitle(branchVersion), + "release pull request title", + ); + requirePendingLabel(pullRequest.labels, "release pull request"); +} + +export function selectPendingReleasePullRequest( + pullRequests, + { eventName, releaseBranch, releaseCommit }, +) { + if (!Array.isArray(pullRequests)) { + fail("Release workflow pending release pull requests must be an array."); + } + requireCommit(releaseCommit, "current release commit"); + const pendingReleasePullRequests = pullRequests.filter( + (pullRequest) => + pullRequest?.baseRef === "main" && + pullRequest?.headRef === releaseBranch && + pullRequest?.state === "closed" && + pullRequest?.mergedAt !== null && + Array.isArray(pullRequest?.labels) && + pullRequest.labels.includes("autorelease: pending"), + ); + if (pendingReleasePullRequests.length > 1) { + fail("Release workflow found multiple pending merged release PRs."); + } + if (pendingReleasePullRequests.length === 0) { + return null; + } + + const pullRequest = pendingReleasePullRequests[0]; + requireEqual( + pullRequest.mergeCommitSha, + releaseCommit, + "pending release pull request merge commit", + ); + requireEqual(eventName, "push", "pending release event"); + return pullRequest; +} + +export function validateReleasePleaseBranchState({ + branchSha, + branchVersion, + exists, + isAncestor, + mainVersion, + manifestVersion, + pullRequests = [], + releaseBranch, +}) { + if (typeof exists !== "boolean" || typeof isAncestor !== "boolean") { + fail("Release Please branch state flags must be boolean."); + } + if (!Array.isArray(pullRequests)) { + fail("Release Please branch pull requests must be an array."); + } + if (!exists) { + return { state: "missing" }; + } + + requireCommit(branchSha, "Release Please branch SHA"); + const mainPatch = stablePatch(mainVersion, "main version"); + const branchPatch = stablePatch( + branchVersion, + "Release Please branch version", + ); + requireEqual( + manifestVersion, + branchVersion, + "Release Please branch manifest version", + ); + + if (isAncestor) { + requireEqual( + branchVersion, + mainVersion, + "merged Release Please branch version", + ); + return { state: "merged-ancestor" }; + } + + if (pullRequests.length !== 1) { + fail( + "Release Please branch is divergent and does not have exactly one matching release PR.", + ); + } + const pullRequest = pullRequests[0]; + requireReleasePullRequest(pullRequest, { + branchSha, + branchVersion, + releaseBranch, + }); + + if (pullRequest.state === "open" && pullRequest.mergedAt === null) { + if (branchPatch !== mainPatch + 1) { + fail("Open Release Please branch must contain the next stable patch."); + } + return { pullRequestNumber: pullRequest.number, state: "open" }; + } + + if (pullRequest.state === "closed" && pullRequest.mergedAt !== null) { + requireEqual( + branchVersion, + mainVersion, + "merged Release Please branch version", + ); + requireEqual( + pullRequest.mergeCommitIsAncestor, + true, + "release pull request merge reachability", + ); + return { pullRequestNumber: pullRequest.number, state: "merged-pr" }; + } + + fail("Release Please branch is associated only with an unmerged closed PR."); +} + +export function validateMergedReleasePullRequest({ + pullRequest, + releaseBranch, + releaseCommit, + reviews, + version, +}) { + requireCommit(releaseCommit, "release commit"); + stablePatch(version, "release version"); + requireReleasePullRequest(pullRequest, { + branchSha: pullRequest?.headSha, + branchVersion: version, + releaseBranch, + }); + if (pullRequest.state !== "closed" || pullRequest.mergedAt === null) { + fail("Release workflow release pull request must be merged."); + } + requireEqual( + pullRequest.mergeCommitSha, + releaseCommit, + "release pull request merge commit", + ); + if (!Array.isArray(reviews)) { + fail("Release workflow release pull request reviews must be an array."); + } + + const latestReviewByUser = new Map(); + for (const review of reviews) { + if ( + review && + typeof review.login === "string" && + Number.isInteger(review.id) && + (!latestReviewByUser.has(review.login) || + latestReviewByUser.get(review.login).id < review.id) + ) { + latestReviewByUser.set(review.login, review); + } + } + const approvedByHumanOwner = [...latestReviewByUser.values()].some( + (review) => + review.state === "APPROVED" && + review.commitId === pullRequest.headSha && + review.permission === "admin" && + review.userType === "User" && + review.login !== pullRequest.author, + ); + if (!approvedByHumanOwner) { + fail( + "Release workflow requires an administrator's human approval on the final release PR head.", + ); + } + + return { pullRequestNumber: pullRequest.number }; +} + +export function validateReleasePleaseActionResult( + result, + { + releaseCommit, + repository, + runAttempt, + runId, + version, + workflowName, + workflowPath, + }, +) { + if (result === null || typeof result !== "object" || Array.isArray(result)) { + fail("Release Please result artifact must be an object."); + } + requireEqual(result.schemaVersion, 1, "result schema version"); + requireEqual(result.releaseCreated, true, "release_created output"); + requireEqual(result.repository, repository, "result repository"); + requireEqual(result.workflowName, workflowName, "result workflow name"); + requireEqual(result.workflowPath, workflowPath, "result workflow path"); + requirePositiveInteger(runId, "run ID"); + requirePositiveInteger(result.runId, "result run ID"); + requireEqual(result.runId, runId, "result run ID"); + requireEqual(runAttempt, 1, "run attempt"); + requireEqual(result.runAttempt, runAttempt, "result run attempt"); + requireCommit(releaseCommit, "release commit"); + requireCommit(result.sha, "result SHA"); + requireEqual(result.sha, releaseCommit, "result SHA"); + stablePatch(version, "release version"); + requireEqual(result.version, version, "result version"); + requireEqual(result.tagName, `v${version}`, "result tag name"); + requireEqual( + result.htmlUrl, + `https://github.com/${repository}/releases/tag/${result.tagName}`, + "result release URL", + ); + + return { + htmlUrl: result.htmlUrl, + releaseCommit: result.sha, + tag: result.tagName, + version: result.version, + }; +} + +export function validateReleaseWorkflowRun( + event, + { checkedOutSha, repository, workflowName, workflowPath }, +) { + const run = event?.workflow_run; + requireEqual(event?.action, "completed", "run action"); + requireEqual(event?.repository?.full_name, repository, "run repository"); + requireEqual(run?.name, workflowName, "run workflow name"); + requireEqual(run?.path, workflowPath, "run workflow path"); + requireEqual(run?.conclusion, "success", "run conclusion"); + requireEqual(run?.event, "push", "run event"); + requireEqual(run?.head_branch, "main", "run head branch"); + requireEqual( + run?.head_repository?.full_name, + repository, + "run head repository", + ); + requirePositiveInteger(run?.id, "run ID"); + requireEqual(run?.run_attempt, 1, "run attempt"); + requireCommit(run?.head_sha, "run head SHA"); + requireCommit(checkedOutSha, "checked-out SHA"); + requireEqual(run.head_sha, checkedOutSha, "run head SHA"); + + return { + releaseCommit: run.head_sha, + runAttempt: run.run_attempt, + runId: run.id, + }; +} + +export function validateGitHubRelease( + release, + { htmlUrl, releaseCommit, tag, tagCommit }, +) { + requireCommit(releaseCommit, "release commit"); + requireCommit(tagCommit, "tag commit"); + requireEqual(tagCommit, releaseCommit, "GitHub release tag commit"); + requireEqual(release?.immutable, true, "GitHub release immutable state"); + requireEqual(release?.draft, false, "GitHub release draft state"); + requireEqual( + release?.author?.login, + "github-actions[bot]", + "GitHub release author", + ); + requireEqual(release?.tag_name, tag, "GitHub release tag"); + requireEqual(release?.name, tag, "GitHub release name"); + requireEqual( + release?.target_commitish, + releaseCommit, + "GitHub release target", + ); + requireEqual(release?.html_url, htmlUrl, "GitHub release URL"); + if ( + typeof release?.published_at !== "string" || + release.published_at === "" + ) { + fail("Release workflow GitHub release must be published."); + } + + const prerelease = tag.slice(1).includes("-"); + requireEqual( + release?.prerelease, + prerelease, + "GitHub release prerelease state", + ); + return { prerelease }; +} diff --git a/src/client.ts b/src/client.ts index a156686..8023ef1 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,11 +1,48 @@ -import { OpenAI, type ClientOptions } from "openai"; +import { OpenAI, OpenAIError, type ClientOptions } from "openai"; import { resolveBaseURL, resolveCometAPIKey } from "./config.js"; +const UNSUPPORTED_COMETAPI_OPTIONS = [ + "provider", + "workloadIdentity", + "dangerouslyAllowBrowser", +] as const satisfies readonly (keyof ClientOptions)[]; +type UnsupportedCometAPIOption = (typeof UNSUPPORTED_COMETAPI_OPTIONS)[number]; + +function sanitizeOptions>( + options: T, +): Omit { + const { + provider, + workloadIdentity, + dangerouslyAllowBrowser, + ...supportedOptions + } = options; + const unsupportedOptions = { + provider, + workloadIdentity, + dangerouslyAllowBrowser, + }; + + for (const option of UNSUPPORTED_COMETAPI_OPTIONS) { + if (unsupportedOptions[option] !== undefined) { + throw new OpenAIError( + `The \`${option}\` option is not supported by CometAPI.`, + ); + } + } + + return supportedOptions; +} + /** Public constructor options accepted by {@link CometAPI}. */ export interface CometAPIOptions extends Omit< ClientOptions, - "apiKey" | "baseURL" + | "apiKey" + | "baseURL" + | "provider" + | "workloadIdentity" + | "dangerouslyAllowBrowser" > { /** CometAPI API key. Defaults to `COMETAPI_KEY`. */ apiKey?: string; @@ -23,15 +60,20 @@ export interface CometAPIOptions extends Omit< */ export class CometAPI extends OpenAI { constructor(options: CometAPIOptions = {}) { + const supportedOptions = sanitizeOptions(options); const { apiKey: explicitAPIKey, baseURL: explicitBaseURL, ...openAIOptions - } = options; + } = supportedOptions; super({ ...openAIOptions, apiKey: resolveCometAPIKey(explicitAPIKey), baseURL: resolveBaseURL(explicitBaseURL), }); } + + override withOptions(options: Partial): this { + return super.withOptions(sanitizeOptions(options)); + } } diff --git a/tests/ci-workflow.test.mjs b/tests/ci-workflow.test.mjs index 050abb3..cd2a30b 100644 --- a/tests/ci-workflow.test.mjs +++ b/tests/ci-workflow.test.mjs @@ -59,7 +59,7 @@ describe("blocking CI workflow", () => { ]); expect(releaseVerify.steps[trustGate]).toMatchObject({ id: "trust", - name: "Reject an untrusted release target", + name: "Reject an untrusted Release Please workflow run", shell: "bash", }); expect(releaseVerify.steps[validationInstall]).toEqual({ @@ -69,8 +69,7 @@ describe("blocking CI workflow", () => { expect(validationInstall).toBeGreaterThan(trustGate); expect(releaseVerify.steps[releaseGate]).toEqual({ env: { - RELEASE_IS_PRERELEASE: "${{ github.event.release.prerelease }}", - RELEASE_TAG: "${{ github.event.release.tag_name }}", + RELEASE_TAG: "${{ steps.trust.outputs.release-tag }}", }, id: "version", name: "Verify release metadata and derive the npm dist-tag", @@ -78,7 +77,6 @@ describe("blocking CI workflow", () => { "set -euo pipefail", "node scripts/validate-release.mjs \\", ' --tag "$RELEASE_TAG" \\', - ' --release-prerelease "$RELEASE_IS_PRERELEASE" \\', " --require-final \\", ' --require-releasable-docs >> "$GITHUB_OUTPUT"', "", diff --git a/tests/config.test.ts b/tests/config.test.ts index 2185a71..bebcddd 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -1,7 +1,7 @@ import { OpenAIError } from "openai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { CometAPI } from "../src/index.js"; +import { CometAPI, type CometAPIOptions } from "../src/index.js"; import { createMockFetch, jsonResponse } from "./helpers/http.js"; const ENV_KEYS = [ @@ -38,6 +38,23 @@ function captureConfigurationError( return caught as OpenAIError; } +function captureWithOptionsError( + client: CometAPI, + options: Partial, +): OpenAIError { + let caught: unknown; + try { + client.withOptions(options); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(OpenAIError); + expect(caught).toBeInstanceOf(Error); + expect((caught as Error).constructor).toBe(OpenAIError); + return caught as OpenAIError; +} + function expectSecretFreeError( error: OpenAIError, secrets: readonly string[], @@ -73,6 +90,7 @@ beforeEach(() => { }); afterEach(() => { + vi.unstubAllGlobals(); for (const key of ENV_KEYS) { const value = savedEnvironment.get(key); if (value === undefined) { @@ -196,6 +214,106 @@ 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) => { + const apiKey = "unsupported-constructor-key-must-not-leak"; + const logger = createLogger(); + const error = captureConfigurationError({ + apiKey, + logger, + logLevel: "debug", + ...unsupportedOptions, + } as unknown as CometAPIOptions); + + expect(error.message).toContain(`\`${optionName}\``); + expect(error.message).toMatch(/not supported by CometAPI/i); + expectSecretFreeError( + error, + [ + apiKey, + "provider-secret-must-not-leak", + "workload-secret-must-not-leak", + ], + logger, + ); + }, + ); + + 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) => { + const apiKey = "unsupported-with-options-key-must-not-leak"; + const logger = createLogger(); + const client = new CometAPI({ apiKey, logger, logLevel: "debug" }); + const error = captureWithOptionsError( + client, + unsupportedOptions as unknown as Partial, + ); + + expect(error.message).toContain(`\`${optionName}\``); + expect(error.message).toMatch(/not supported by CometAPI/i); + expectSecretFreeError( + error, + [ + apiKey, + "provider-secret-must-not-leak", + "workload-secret-must-not-leak", + ], + logger, + ); + }, + ); + + it("cannot enable browser use by changing an accessor after validation", () => { + const browserKey = "browser-accessor-key-must-not-leak"; + const logger = createLogger(); + let reads = 0; + const options = Object.defineProperty( + { apiKey: browserKey, logger, logLevel: "debug" }, + "dangerouslyAllowBrowser", + { + enumerable: true, + get() { + reads += 1; + return reads === 1 ? undefined : true; + }, + }, + ); + vi.stubGlobal("window", { document: {} }); + vi.stubGlobal("navigator", {}); + + const error = captureConfigurationError( + options as unknown as CometAPIOptions, + ); + + expect(reads).toBeGreaterThan(0); + expect(error.message).toMatch(/browser-like environment/i); + expectSecretFreeError(error, [browserKey], logger); + }); + it("preserves inherited public client options across withOptions", async () => { const original = createMockFetch(() => jsonResponse({ object: "list", data: [] }), @@ -203,12 +321,21 @@ describe("CometAPI configuration", () => { const overridden = createMockFetch(() => jsonResponse({ object: "list", data: [] }), ); + const logger = createLogger(); const client = new CometAPI({ + adminAPIKey: "test-admin-key", apiKey: "test-lifecycle-key", baseURL: "https://original.example.test/v1", + defaultHeaders: { "x-default-option": "preserved" }, + defaultQuery: { source: "with-options" }, fetch: original.fetch, + fetchOptions: { cache: "no-store" }, + logger, maxRetries: 0, + organization: "test-organization", + project: "test-project", timeout: 2_000, + webhookSecret: "test-webhook-secret", }); const derived = client.withOptions({ @@ -219,14 +346,22 @@ describe("CometAPI configuration", () => { await derived.models.list(); expect(derived).toBeInstanceOf(CometAPI); + expect(derived.adminAPIKey).toBe("test-admin-key"); + expect(derived.logger).toBe(logger); expect(derived.maxRetries).toBe(0); + expect(derived.organization).toBe("test-organization"); + expect(derived.project).toBe("test-project"); expect(derived.timeout).toBe(1_000); + expect(derived.webhookSecret).toBe("test-webhook-secret"); expect(original.requests).toHaveLength(0); - expect(overridden.requests[0]?.url).toBe( - "https://derived.example.test/v1/models", + const request = overridden.requests[0]; + expect(request?.url).toBe( + "https://derived.example.test/v1/models?source=with-options", ); - expect(overridden.requests[0]?.headers.get("authorization")).toBe( + expect(request?.headers.get("authorization")).toBe( "Bearer test-lifecycle-key", ); + expect(request?.headers.get("x-default-option")).toBe("preserved"); + expect(request?.init?.cache).toBe("no-store"); }); }); diff --git a/tests/options-types.ts b/tests/options-types.ts new file mode 100644 index 0000000..7214c1e --- /dev/null +++ b/tests/options-types.ts @@ -0,0 +1,60 @@ +import type { ClientOptions } from "openai"; + +import { CometAPI, type CometAPIOptions } from "../src/index.js"; + +const supportedOptions: CometAPIOptions = { + adminAPIKey: "test-admin-key", + apiKey: "test-api-key", + baseURL: "https://options.example.test/v1", + defaultHeaders: { "x-options-test": "header" }, + defaultQuery: { source: "type-test" }, + fetch: globalThis.fetch, + fetchOptions: { cache: "no-store" }, + logger: console, + logLevel: "warn", + maxRetries: 1, + organization: "test-organization", + project: "test-project", + timeout: 1_000, + webhookSecret: "test-webhook-secret", +}; + +const client = new CometAPI(supportedOptions); +client.withOptions({ + baseURL: "https://derived-options.example.test/v1", + defaultHeaders: { "x-derived-options-test": "header" }, + defaultQuery: { source: "derived-type-test" }, + fetch: globalThis.fetch, + fetchOptions: { cache: "reload" }, + logger: console, + maxRetries: 0, + timeout: 2_000, +}); + +const providerOptions: CometAPIOptions = { + // @ts-expect-error CometAPI owns routing and does not accept OpenAI providers. + provider: {} as NonNullable, +}; +const workloadIdentityOptions: CometAPIOptions = { + // @ts-expect-error CometAPI owns API-key authentication. + workloadIdentity: {} as NonNullable, +}; +const browserOptions: CometAPIOptions = { + // @ts-expect-error Browser-side long-lived key use is unsupported. + dangerouslyAllowBrowser: true, +}; + +client.withOptions({ + // @ts-expect-error withOptions must not expose OpenAI provider routing. + provider: {} as NonNullable, +}); +client.withOptions({ + // @ts-expect-error withOptions must not expose workload identity authentication. + workloadIdentity: {} as NonNullable, +}); +client.withOptions({ + // @ts-expect-error withOptions must not expose the browser safety bypass. + dangerouslyAllowBrowser: true, +}); + +void [browserOptions, providerOptions, workloadIdentityOptions]; diff --git a/tests/release-validation.test.mjs b/tests/release-validation.test.mjs index 861b0c0..1713809 100644 --- a/tests/release-validation.test.mjs +++ b/tests/release-validation.test.mjs @@ -120,15 +120,25 @@ function fixture(version = "0.1.0-alpha.1") { } : { "release-type": "node", - versioning: "prerelease", + versioning: "default", prerelease: false, - "skip-github-release": true, + component: "cometapi", + "skip-github-release": false, "changelog-path": "CHANGELOG.md", "include-component-in-tag": false, "include-v-in-tag": true, "include-v-in-release-name": true, + "pull-request-title-pattern": + "chore${scope}: release${component} ${version}", }, }, + ...(isPrerelease + ? {} + : { + label: "autorelease: pending", + "release-label": "autorelease: tagged", + "separate-pull-requests": true, + }), }, releaseManifest: { ".": version }, sourceManifest, @@ -666,12 +676,116 @@ describe("release metadata validation", () => { "rejects stable version %s outside the 0.1.0 promotion", (version) => { const values = fixture(version); - expect(() => validateReleaseMetadata(values)).toThrow( - /exactly version 0\.1\.0/, - ); + expect(() => validateReleaseMetadata(values)).toThrow(/within 0\.1\.x/); }, ); + it("accepts a normal stable 0.1.1 patch release state", () => { + const values = fixture("0.1.1"); + values.releaseConfig = { + label: "autorelease: pending", + "release-label": "autorelease: tagged", + "separate-pull-requests": true, + packages: { + ".": { + "changelog-path": "CHANGELOG.md", + component: "cometapi", + "include-component-in-tag": false, + "include-v-in-release-name": true, + "include-v-in-tag": true, + prerelease: false, + "pull-request-title-pattern": + "chore${scope}: release${component} ${version}", + "release-type": "node", + "skip-github-release": false, + versioning: "default", + }, + }, + }; + values.releaseManifest = { ".": "0.1.1" }; + + expect(validateReleaseMetadata(values)).toMatchObject({ + isPrerelease: false, + version: "0.1.1", + }); + }); + + it.each([ + ["component", (config) => delete config.packages["."].component], + [ + "default versioning", + (config) => (config.packages["."].versioning = "prerelease"), + ], + [ + "GitHub release", + (config) => (config.packages["."]["skip-github-release"] = true), + ], + [ + "separate pull requests", + (config) => delete config["separate-pull-requests"], + ], + [ + "release PR title", + (config) => delete config.packages["."]["pull-request-title-pattern"], + ], + ["pending label", (config) => delete config.label], + ["release label", (config) => delete config["release-label"]], + ])("rejects stable maintenance without %s configuration", (_name, mutate) => { + const values = fixture("0.1.1"); + values.releaseConfig = { + label: "autorelease: pending", + "release-label": "autorelease: tagged", + "separate-pull-requests": true, + packages: { + ".": { + "changelog-path": "CHANGELOG.md", + component: "cometapi", + "include-component-in-tag": false, + "include-v-in-release-name": true, + "include-v-in-tag": true, + prerelease: false, + "pull-request-title-pattern": + "chore${scope}: release${component} ${version}", + "release-type": "node", + "skip-github-release": false, + versioning: "default", + }, + }, + }; + values.releaseManifest = { ".": "0.1.1" }; + mutate(values.releaseConfig); + + expect(() => validateReleaseMetadata(values)).toThrow(/Release Please/); + }); + + it("allows the exact one-cycle stable boundary only before 0.1.1", () => { + const values = fixture("0.1.1"); + values.releaseConfig = { + label: "autorelease: pending", + "last-release-sha": "1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca", + "release-label": "autorelease: tagged", + "separate-pull-requests": true, + packages: { + ".": { + "changelog-path": "CHANGELOG.md", + component: "cometapi", + "include-component-in-tag": false, + "include-v-in-release-name": true, + "include-v-in-tag": true, + prerelease: false, + "pull-request-title-pattern": + "chore${scope}: release${component} ${version}", + "release-type": "node", + "skip-github-release": false, + versioning: "default", + }, + }, + }; + values.releaseManifest = { ".": "0.1.1" }; + + expect(() => validateReleaseMetadata(values)).toThrow(/last-release-sha/); + }); + it("rejects additional Release Please packages", () => { const values = fixture("0.1.0"); values.releaseConfig.packages.other = { diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs new file mode 100644 index 0000000..aa4d5ec --- /dev/null +++ b/tests/release-workflow-validation.test.mjs @@ -0,0 +1,422 @@ +import { describe, expect, it } from "vitest"; + +import { + validateGitHubRelease, + validateMergedReleasePullRequest, + validateReleasePleaseActionResult, + validateReleasePleaseBranchState, + validateReleaseWorkflowRun, + selectPendingReleasePullRequest, +} from "../scripts/release-workflow-validation.mjs"; + +const REPOSITORY = "cometapi-dev/cometapi-node"; +const RELEASE_BRANCH = "release-please--branches--main--components--cometapi"; +const RELEASE_SHA = "a".repeat(40); +const BRANCH_SHA = "b".repeat(40); +const RUN_ID = 123456789; + +function pullRequestFixture({ merged = false, version = "0.1.1" } = {}) { + return { + author: "release-author", + baseRef: "main", + headRef: RELEASE_BRANCH, + headSha: BRANCH_SHA, + labels: ["autorelease: pending"], + mergeCommitIsAncestor: merged, + mergeCommitSha: merged ? RELEASE_SHA : null, + mergedAt: merged ? "2026-07-29T00:00:00Z" : null, + number: 31, + state: merged ? "closed" : "open", + title: `chore(main): release cometapi ${version}`, + }; +} + +function workflowRunFixture() { + return { + action: "completed", + repository: { full_name: REPOSITORY }, + workflow_run: { + conclusion: "success", + event: "push", + head_branch: "main", + head_repository: { full_name: REPOSITORY }, + head_sha: RELEASE_SHA, + id: RUN_ID, + name: "Release Please", + path: ".github/workflows/release-please.yml", + run_attempt: 1, + }, + }; +} + +function actionResultFixture() { + return { + htmlUrl: `${releaseUrl()}`, + releaseCreated: true, + repository: REPOSITORY, + runAttempt: 1, + runId: RUN_ID, + schemaVersion: 1, + sha: RELEASE_SHA, + tagName: "v0.1.1", + version: "0.1.1", + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }; +} + +function releaseUrl() { + return `https://github.com/${REPOSITORY}/releases/tag/v0.1.1`; +} + +function releaseFixture() { + return { + author: { login: "github-actions[bot]" }, + draft: false, + html_url: releaseUrl(), + immutable: true, + name: "v0.1.1", + prerelease: false, + published_at: "2026-07-29T00:01:00Z", + tag_name: "v0.1.1", + target_commitish: RELEASE_SHA, + }; +} + +function branchState(overrides = {}) { + return { + branchSha: BRANCH_SHA, + branchVersion: "0.1.1", + exists: true, + isAncestor: false, + mainVersion: "0.1.0", + manifestVersion: "0.1.1", + pullRequests: [pullRequestFixture()], + releaseBranch: RELEASE_BRANCH, + ...overrides, + }; +} + +describe("Release Please branch validation", () => { + it("accepts a missing release branch", () => { + expect( + validateReleasePleaseBranchState( + branchState({ exists: false, pullRequests: [] }), + ), + ).toEqual({ state: "missing" }); + }); + + it("accepts a merged branch that is an ancestor of main", () => { + expect( + validateReleasePleaseBranchState( + branchState({ + branchVersion: "0.1.1", + isAncestor: true, + mainVersion: "0.1.1", + pullRequests: [], + }), + ), + ).toEqual({ state: "merged-ancestor" }); + }); + + it("accepts the exact next-patch branch with one open release PR", () => { + expect(validateReleasePleaseBranchState(branchState())).toEqual({ + pullRequestNumber: 31, + state: "open", + }); + }); + + it("accepts a squash-merged branch through its exact merged PR", () => { + expect( + validateReleasePleaseBranchState( + branchState({ + branchVersion: "0.1.1", + mainVersion: "0.1.1", + pullRequests: [pullRequestFixture({ merged: true })], + }), + ), + ).toEqual({ pullRequestNumber: 31, state: "merged-pr" }); + }); + + it("rejects the stale 0.2.0 branch even if someone opens a PR for it", () => { + expect(() => + validateReleasePleaseBranchState( + branchState({ + branchVersion: "0.2.0", + manifestVersion: "0.2.0", + pullRequests: [pullRequestFixture({ version: "0.2.0" })], + }), + ), + ).toThrow(/stable 0\.1\.x/i); + }); + + it.each([ + ["no PR", () => []], + ["multiple PRs", (pr) => [pr, { ...pr, number: 32 }]], + ])("rejects a divergent branch with %s", (_name, mutate) => { + const pullRequest = pullRequestFixture(); + expect(() => + validateReleasePleaseBranchState( + branchState({ pullRequests: mutate(pullRequest) }), + ), + ).toThrow(/exactly one matching release PR/i); + }); + + it.each([ + ["base", (pr) => (pr.baseRef = "dev")], + ["head", (pr) => (pr.headRef = "other")], + ["head SHA", (pr) => (pr.headSha = "c".repeat(40))], + ["title", (pr) => (pr.title = "chore(main): release 0.2.0")], + ["label", (pr) => (pr.labels = [])], + ])("rejects a release PR with the wrong %s", (_name, mutate) => { + const pullRequest = pullRequestFixture(); + mutate(pullRequest); + expect(() => + validateReleasePleaseBranchState( + branchState({ pullRequests: [pullRequest] }), + ), + ).toThrow(/release workflow/i); + }); +}); + +describe("release PR review validation", () => { + function reviewFixture() { + return { + commitId: BRANCH_SHA, + id: 10, + login: "human-owner", + permission: "admin", + state: "APPROVED", + userType: "User", + }; + } + + it("selects the unique pending release PR for the current push", () => { + const pullRequest = pullRequestFixture({ merged: true }); + expect( + selectPendingReleasePullRequest([pullRequest], { + eventName: "push", + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + }), + ).toBe(pullRequest); + }); + + it("allows preparation when no pending merged release PR exists", () => { + expect( + selectPendingReleasePullRequest([pullRequestFixture()], { + eventName: "workflow_dispatch", + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + }), + ).toBeNull(); + }); + + it.each([ + [ + "an older merge", + [pullRequestFixture({ merged: true })], + { eventName: "push", releaseCommit: "c".repeat(40) }, + ], + [ + "a manual release dispatch", + [pullRequestFixture({ merged: true })], + { eventName: "workflow_dispatch", releaseCommit: RELEASE_SHA }, + ], + [ + "multiple pending merges", + [ + pullRequestFixture({ merged: true }), + { ...pullRequestFixture({ merged: true }), number: 32 }, + ], + { eventName: "push", releaseCommit: RELEASE_SHA }, + ], + ])( + "rejects %s before Release Please runs", + (_name, pullRequests, overrides) => { + expect(() => + selectPendingReleasePullRequest(pullRequests, { + eventName: overrides.eventName, + releaseBranch: RELEASE_BRANCH, + releaseCommit: overrides.releaseCommit, + }), + ).toThrow(/release workflow/i); + }, + ); + + it("accepts an administrator's approval on the final release PR head", () => { + expect( + validateMergedReleasePullRequest({ + pullRequest: pullRequestFixture({ merged: true }), + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + reviews: [reviewFixture()], + version: "0.1.1", + }), + ).toEqual({ pullRequestNumber: 31 }); + }); + + it.each([ + ["stale commit", (review) => (review.commitId = "c".repeat(40))], + ["non-admin", (review) => (review.permission = "maintain")], + ["bot", (review) => (review.userType = "Bot")], + ["PR author", (review) => (review.login = "release-author")], + ["changes requested", (review) => (review.state = "CHANGES_REQUESTED")], + ])("rejects a %s review", (_name, mutate) => { + const review = reviewFixture(); + mutate(review); + expect(() => + validateMergedReleasePullRequest({ + pullRequest: pullRequestFixture({ merged: true }), + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + reviews: [review], + version: "0.1.1", + }), + ).toThrow(/human approval/i); + }); + + it("uses each reviewer's latest decision", () => { + const approval = reviewFixture(); + expect(() => + validateMergedReleasePullRequest({ + pullRequest: pullRequestFixture({ merged: true }), + releaseBranch: RELEASE_BRANCH, + releaseCommit: RELEASE_SHA, + reviews: [ + approval, + { ...approval, id: 11, state: "CHANGES_REQUESTED" }, + ], + version: "0.1.1", + }), + ).toThrow(/human approval/i); + }); +}); + +describe("release workflow trust validation", () => { + it("accepts the exact successful first-attempt main run", () => { + expect( + validateReleaseWorkflowRun(workflowRunFixture(), { + checkedOutSha: RELEASE_SHA, + repository: REPOSITORY, + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toEqual({ releaseCommit: RELEASE_SHA, runAttempt: 1, runId: RUN_ID }); + }); + + it.each([ + ["action", (event) => (event.action = "requested")], + ["conclusion", (event) => (event.workflow_run.conclusion = "failure")], + ["event", (event) => (event.workflow_run.event = "workflow_dispatch")], + ["head branch", (event) => (event.workflow_run.head_branch = "feature")], + [ + "head repository", + (event) => (event.workflow_run.head_repository.full_name = "fork/repo"), + ], + ["repository", (event) => (event.repository.full_name = "fork/repo")], + ["workflow name", (event) => (event.workflow_run.name = "Other")], + ["workflow path", (event) => (event.workflow_run.path = "other.yml")], + ["workflow SHA", (event) => (event.workflow_run.head_sha = "b".repeat(40))], + ["rerun", (event) => (event.workflow_run.run_attempt = 2)], + ])("rejects a hostile or stale %s", (_name, mutate) => { + const event = workflowRunFixture(); + mutate(event); + expect(() => + validateReleaseWorkflowRun(event, { + checkedOutSha: RELEASE_SHA, + repository: REPOSITORY, + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toThrow(/release workflow/i); + }); + + it("accepts the exact Release Please outputs artifact", () => { + expect( + validateReleasePleaseActionResult(actionResultFixture(), { + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 1, + runId: RUN_ID, + version: "0.1.1", + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toEqual({ + htmlUrl: releaseUrl(), + releaseCommit: RELEASE_SHA, + tag: "v0.1.1", + version: "0.1.1", + }); + }); + + it.each([ + ["release_created", (result) => (result.releaseCreated = false)], + ["repository", (result) => (result.repository = "fork/repo")], + ["run ID", (result) => (result.runId += 1)], + ["run attempt", (result) => (result.runAttempt = 2)], + ["SHA", (result) => (result.sha = "b".repeat(40))], + ["tag", (result) => (result.tagName = "v0.2.0")], + ["version", (result) => (result.version = "0.2.0")], + ["URL", (result) => (result.htmlUrl = `${releaseUrl()}-other`)], + ])("rejects a mismatched action result %s", (_name, mutate) => { + const result = actionResultFixture(); + mutate(result); + expect(() => + validateReleasePleaseActionResult(result, { + releaseCommit: RELEASE_SHA, + repository: REPOSITORY, + runAttempt: 1, + runId: RUN_ID, + version: "0.1.1", + workflowName: "Release Please", + workflowPath: ".github/workflows/release-please.yml", + }), + ).toThrow(/release workflow/i); + }); + + it("accepts the exact immutable Release Please release", () => { + expect( + validateGitHubRelease(releaseFixture(), { + htmlUrl: releaseUrl(), + releaseCommit: RELEASE_SHA, + tag: "v0.1.1", + tagCommit: RELEASE_SHA, + }), + ).toEqual({ prerelease: false }); + }); + + it.each([ + ["mutable", (release) => (release.immutable = false)], + ["draft", (release) => (release.draft = true)], + ["wrong tag", (release) => (release.tag_name = "v0.2.0")], + ["wrong name", (release) => (release.name = "v0.2.0")], + ["wrong target", (release) => (release.target_commitish = "b".repeat(40))], + ["wrong URL", (release) => (release.html_url = `${releaseUrl()}-other`)], + ["unpublished", (release) => (release.published_at = null)], + ["manual author", (release) => (release.author.login = "maintainer")], + ])("rejects a %s GitHub release", (_name, mutate) => { + const release = releaseFixture(); + mutate(release); + expect(() => + validateGitHubRelease(release, { + htmlUrl: releaseUrl(), + releaseCommit: RELEASE_SHA, + tag: "v0.1.1", + tagCommit: RELEASE_SHA, + }), + ).toThrow(/release workflow/i); + }); + + it("rejects a tag that does not resolve to the workflow commit", () => { + expect(() => + validateGitHubRelease(releaseFixture(), { + htmlUrl: releaseUrl(), + releaseCommit: RELEASE_SHA, + tag: "v0.1.1", + tagCommit: "b".repeat(40), + }), + ).toThrow(/tag commit/i); + }); +}); diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index ee6e195..abb324c 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -18,6 +18,16 @@ const workflows = Object.fromEntries( ), ]), ); +const releasePleaseConfig = JSON.parse( + readFileSync( + new URL("../release-please-config.json", import.meta.url), + "utf8", + ), +); +const releaseWorkflowValidation = readFileSync( + new URL("../scripts/release-workflow-validation.mjs", import.meta.url), + "utf8", +); function workflow(name) { const contents = workflows[name]; @@ -85,9 +95,7 @@ describe("GitHub Actions workflow contract", () => { expect(verify).toContain( "EXPECTED_BUGS_URL: https://github.com/cometapi-dev/cometapi-node/issues", ); - expect(verify).toContain( - '[[ "$GITHUB_REPOSITORY" != "$EXPECTED_REPOSITORY" ]]', - ); + expect(verify).toContain("validateReleaseWorkflowRun"); expect(verify).not.toContain( "EXPECTED_REPOSITORY: ${{ github.repository }}", ); @@ -152,9 +160,12 @@ describe("GitHub Actions workflow contract", () => { }); it("keeps elevated permissions scoped to their required jobs", () => { - for (const name of workflowNames) { + for (const name of workflowNames.filter((name) => name !== "publish.yml")) { expect(workflow(name)).toMatch(/^permissions:\n {2}contents: read$/m); } + expect(workflow("publish.yml")).toMatch( + /^permissions:\n {2}actions: read\n {2}contents: read$/m, + ); const ci = workflow("ci.yml"); const liveSmoke = workflow("live-smoke.yml"); @@ -163,6 +174,7 @@ describe("GitHub Actions workflow contract", () => { const releasePlease = job(workflow("release-please.yml"), "release-please"); expect(releasePlease).toMatch(/^ {6}contents: write$/m); + expect(releasePlease).toMatch(/^ {6}issues: write$/m); expect(releasePlease).toMatch(/^ {6}pull-requests: write$/m); const publishWorkflow = workflow("publish.yml"); @@ -172,13 +184,87 @@ describe("GitHub Actions workflow contract", () => { expect(job(publishWorkflow, "publish")).toContain("id-token: write"); }); - it("uses Release Please only to prepare the reviewed stable pull request", () => { + it("uses Release Please for the reviewed patch PR and immutable release", () => { 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(releasePlease).not.toContain("skip-github-release: true"); expect(contents).not.toContain("token:"); + expect(contents).toMatch(/^ {2}workflow_dispatch:$/m); + expect(releasePlease).toContain("RUN_ATTEMPT: ${{ github.run_attempt }}"); + expect(releasePlease).toContain("ref: ${{ github.sha }}"); + expect( + matches( + releasePlease, + /git fetch --no-tags origin \+refs\/heads\/main:refs\/remotes\/origin\/main/g, + ), + ).toHaveLength(2); + expect(releasePlease).toContain("validateMergedReleasePullRequest"); + expect(releasePlease).toContain("selectPendingReleasePullRequest"); + expect(releasePlease).toContain( + "release-please-result-${{ github.run_id }}-${{ github.run_attempt }}", + ); + expect(releasePlease).toContain("validateReleasePleaseActionResult"); + + expect(releasePleaseConfig["last-release-sha"]).toBe( + "1752cbb57f11dc6dca8dd1b13f0f8d5e8b5fdfca", + ); + expect(releasePleaseConfig.label).toBe("autorelease: pending"); + expect(releasePleaseConfig["release-label"]).toBe("autorelease: tagged"); + expect(releasePleaseConfig["separate-pull-requests"]).toBe(true); + expect(releasePleaseConfig.packages["."]).toMatchObject({ + component: "cometapi", + "include-component-in-tag": false, + "pull-request-title-pattern": + "chore${scope}: release${component} ${version}", + "release-type": "node", + "skip-github-release": false, + versioning: "default", + }); + }); + + it("starts publication only from the completed Release Please workflow", () => { + const publish = workflow("publish.yml"); + expect(publish).toMatch( + /workflow_run:\n {4}workflows:\n {6}- Release Please\n {4}types:\n {6}- completed/, + ); + expect(publish).not.toMatch(/^ {2}release:/m); + + const verify = job(publish, "verify"); + expect(verify).toContain( + "github.event.workflow_run.conclusion == 'success'", + ); + expect(verify).toContain("ref: refs/heads/main"); + expect(verify).toContain("EXPECTED_WORKFLOW: Release Please"); + expect(verify).toContain( + "EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml", + ); + expect(verify).toContain("github.event.workflow_run.head_sha"); + expect(verify).toContain("github.event.workflow_run.run_attempt"); + expect(verify).toContain( + "release-please-result-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}", + ); + expect(verify).toContain("validateReleasePleaseActionResult"); + expect(verify).toContain("validateGitHubRelease"); + expect(releaseWorkflowValidation).toContain("run?.run_attempt"); + expect(releaseWorkflowValidation).toContain("result.releaseCreated"); + expect(releaseWorkflowValidation).toContain("release?.immutable"); + expect(releaseWorkflowValidation).toContain("release?.target_commitish"); + }); + + it("rejects an unrelated divergent Release Please branch", () => { + const releasePlease = job(workflow("release-please.yml"), "release-please"); + expect(releasePlease).toContain( + "RELEASE_BRANCH: release-please--branches--main--components--cometapi", + ); + expect(releasePlease).toContain( + 'git merge-base --is-ancestor "$release_ref" refs/remotes/origin/main', + ); + expect(releasePlease).toContain("branch_version="); + expect(releasePlease).toContain("manifest_version="); + expect(releasePlease).toContain("pullRequest.head?.sha === branchSha"); + expect(releasePlease).toContain("validateReleasePleaseBranchState"); }); });