Publish #10
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Publish | |
| on: | |
| workflow_run: | |
| workflows: | |
| - Release Please | |
| types: | |
| - completed | |
| permissions: | |
| actions: read | |
| contents: read | |
| concurrency: | |
| group: npm-publish | |
| cancel-in-progress: false | |
| jobs: | |
| verify: | |
| name: Verify the immutable release artifact | |
| 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: | |
| artifact-name: ${{ steps.artifact-name.outputs.name }} | |
| 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 current main branch | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 0 | |
| persist-credentials: false | |
| 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 | |
| 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 | |
| head_commit="$(git rev-parse HEAD)" | |
| 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 rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then | |
| echo "origin/main moved after the successful Release Please run." >&2 | |
| exit 1 | |
| fi | |
| 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 !== process.env.EXPECTED_REPOSITORY_URL | |
| ) { | |
| throw new Error( | |
| `package.json repository must equal ${process.env.EXPECTED_REPOSITORY_URL}.`, | |
| ); | |
| } | |
| 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 | |
| - name: Set up Node.js 24 | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: 24.x | |
| cache: npm | |
| - name: Install validation dependencies without lifecycle scripts | |
| run: npm ci --ignore-scripts | |
| - name: Verify release metadata and derive the npm dist-tag | |
| id: version | |
| env: | |
| RELEASE_TAG: ${{ steps.trust.outputs.release-tag }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| node scripts/validate-release.mjs \ | |
| --tag "$RELEASE_TAG" \ | |
| --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 { | |
| extractReleaseNotesFromChangelog, | |
| validateGitHubRelease, | |
| } from "./scripts/release-workflow-validation.mjs"; | |
| const release = JSON.parse(readFileSync(process.env.RELEASE_JSON, "utf8")); | |
| const version = JSON.parse(readFileSync("package.json", "utf8")).version; | |
| validateGitHubRelease(release, { | |
| expectedBody: extractReleaseNotesFromChangelog( | |
| readFileSync("CHANGELOG.md", "utf8"), | |
| version, | |
| ), | |
| 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 | |
| run: npm ci | |
| - name: Run release checks | |
| run: | | |
| npm run format:check | |
| npm run lint | |
| npm run typecheck | |
| npm test | |
| npm run test:secrets | |
| npm run build | |
| npm run test:package | |
| npm run test:live-contract | |
| npm run test:compat -- --lane locked | |
| npm run check:self-contained | |
| - name: Pack the exact release artifact | |
| id: pack | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mkdir -p release-artifacts | |
| npm pack --pack-destination release-artifacts | |
| mapfile -t tarballs < <(find release-artifacts -maxdepth 1 -type f -name '*.tgz' -print) | |
| if [[ "${#tarballs[@]}" -ne 1 ]]; then | |
| echo "Expected exactly one packed artifact, found ${#tarballs[@]}." >&2 | |
| exit 1 | |
| fi | |
| echo "tarball=${tarballs[0]}" >> "$GITHUB_OUTPUT" | |
| - name: Test consumers against the exact release artifact | |
| run: | | |
| npm run test:package -- \ | |
| --tarball "${{ steps.pack.outputs.tarball }}" \ | |
| --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: Name the attempt-qualified release artifact | |
| id: artifact-name | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| echo "name=npm-package-${{ steps.version.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" | |
| - name: Upload the verified release artifact | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 | |
| with: | |
| name: ${{ steps.artifact-name.outputs.name }} | |
| path: ${{ steps.pack.outputs.tarball }} | |
| if-no-files-found: error | |
| retention-days: 30 | |
| live-smoke: | |
| name: Verify the release tag against CometAPI | |
| needs: | |
| - verify | |
| concurrency: | |
| group: live-smoke | |
| cancel-in-progress: false | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| # Required repository configuration: configure the protected live-smoke environment | |
| # without required reviewers and add COMETAPI_KEY before publishing a release. | |
| environment: live-smoke | |
| steps: | |
| - name: Check out the verified release tag | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| ref: ${{ needs.verify.outputs.release-commit }} | |
| - name: Set up Node.js 24 | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: 24.x | |
| cache: npm | |
| - name: Install locked dependencies | |
| run: npm ci | |
| - name: Build the release tag | |
| run: npm run build | |
| - name: Run the bounded live smoke | |
| env: | |
| COMETAPI_KEY: ${{ secrets.COMETAPI_KEY }} | |
| COMETAPI_LIVE_SMOKE: "1" | |
| COMETAPI_SMOKE_MODEL: ${{ vars.COMETAPI_SMOKE_MODEL || 'gpt-5.4' }} | |
| COMETAPI_LIVE_REQUEST_LIMIT: "3" | |
| COMETAPI_LIVE_MAX_OUTPUT_TOKENS: "16" | |
| COMETAPI_LIVE_REQUEST_TIMEOUT_MS: "60000" | |
| COMETAPI_LIVE_CONCURRENCY: "1" | |
| run: npm run test:live | |
| publish: | |
| name: Publish with npm Trusted Publishing | |
| needs: | |
| - live-smoke | |
| - verify | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Required repository configuration: configure the protected npm environment with | |
| # approval by the current release approver and self-review allowed, plus package ownership. | |
| environment: | |
| name: npm | |
| url: https://www.npmjs.com/package/cometapi/v/${{ needs.verify.outputs.version }} | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - name: Check out the verified release commit | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| persist-credentials: false | |
| ref: ${{ needs.verify.outputs.release-commit }} | |
| - name: Set up Node.js 24 | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: 24.x | |
| package-manager-cache: false | |
| registry-url: https://registry.npmjs.org | |
| - name: Use a Trusted Publishing-capable npm CLI | |
| run: npm install --global npm@11.12.1 | |
| - name: Download the verified release artifact | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 | |
| with: | |
| name: ${{ needs.verify.outputs.artifact-name }} | |
| path: release-artifacts | |
| - name: Publish the exact artifact with provenance | |
| env: | |
| DIST_TAG: ${{ needs.verify.outputs.dist-tag }} | |
| VERSION: ${{ needs.verify.outputs.version }} | |
| run: bash scripts/publish-artifact.sh | |
| - name: Verify the public registry artifact | |
| env: | |
| DIST_TAG: ${{ needs.verify.outputs.dist-tag }} | |
| VERSION: ${{ needs.verify.outputs.version }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| mapfile -t tarballs < <(find "$GITHUB_WORKSPACE/release-artifacts" -maxdepth 1 -type f -name '*.tgz' -print) | |
| if [[ "${#tarballs[@]}" -ne 1 ]]; then | |
| echo "Expected exactly one downloaded artifact for registry verification." >&2 | |
| exit 1 | |
| fi | |
| local_integrity="$(node -e 'const {createHash}=require("node:crypto");const {readFileSync}=require("node:fs");process.stdout.write("sha512-"+createHash("sha512").update(readFileSync(process.argv[1])).digest("base64"))' "${tarballs[0]}")" | |
| registry_ready="false" | |
| for attempt in {1..12}; do | |
| resolved="$(npm view "cometapi@${VERSION}" version 2>/dev/null || true)" | |
| tagged="$(npm view "cometapi@${DIST_TAG}" version 2>/dev/null || true)" | |
| registry_dist="$(npm view "cometapi@${VERSION}" dist --json 2>/dev/null || true)" | |
| if [[ "$resolved" == "$VERSION" && "$tagged" == "$VERSION" && -n "$registry_dist" ]] && \ | |
| REGISTRY_DIST="$registry_dist" LOCAL_INTEGRITY="$local_integrity" node <<'EOF' | |
| let ready = false; | |
| try { | |
| const dist = JSON.parse(process.env.REGISTRY_DIST); | |
| ready = | |
| dist.integrity === process.env.LOCAL_INTEGRITY && | |
| Boolean(dist.attestations?.url) && | |
| dist.attestations?.provenance?.predicateType === | |
| "https://slsa.dev/provenance/v1"; | |
| } catch {} | |
| process.exitCode = ready ? 0 : 1; | |
| EOF | |
| then | |
| registry_ready="true" | |
| break | |
| fi | |
| if [[ "$attempt" -lt 12 ]]; then | |
| sleep 10 | |
| fi | |
| done | |
| if [[ "$registry_ready" != "true" ]]; then | |
| echo "Registry state did not converge for cometapi@${VERSION}, ${DIST_TAG}, integrity, and provenance." >&2 | |
| exit 1 | |
| fi | |
| verify_dir="$(mktemp -d)" | |
| cd "$verify_dir" | |
| npm init --yes >/dev/null | |
| npm install --ignore-scripts --no-audit --no-fund \ | |
| "openai@6.47.0" "cometapi@${VERSION}" | |
| signatures_verified="false" | |
| for attempt in {1..3}; do | |
| if npm audit signatures; then | |
| signatures_verified="true" | |
| break | |
| fi | |
| if [[ "$attempt" -lt 3 ]]; then | |
| sleep 10 | |
| fi | |
| done | |
| if [[ "$signatures_verified" != "true" ]]; then | |
| echo "Registry signature and provenance verification did not converge." >&2 | |
| exit 1 | |
| fi | |
| npm ls openai --all | |
| if [[ -d node_modules/cometapi/node_modules/openai ]]; then | |
| echo "The registry fixture contains a nested OpenAI installation." >&2 | |
| exit 1 | |
| fi | |
| node --input-type=module <<'EOF' | |
| import assert from "node:assert/strict"; | |
| import { CometAPI } from "cometapi"; | |
| const client = new CometAPI({ | |
| apiKey: "mock-registry-key", | |
| maxRetries: 0, | |
| fetch: async () => | |
| new Response(JSON.stringify({ object: "list", data: [] }), { | |
| status: 200, | |
| headers: { "content-type": "application/json" }, | |
| }), | |
| }); | |
| const models = await client.models.list(); | |
| assert.deepEqual(models.data, []); | |
| EOF | |
| node <<'EOF' | |
| const assert = require("node:assert/strict"); | |
| const { CometAPI } = require("cometapi"); | |
| const { APIError } = require("openai"); | |
| const client = new CometAPI({ | |
| apiKey: "mock-registry-key", | |
| maxRetries: 0, | |
| fetch: async () => | |
| new Response( | |
| JSON.stringify({ | |
| error: { | |
| message: "mock registry failure", | |
| type: "invalid_request_error", | |
| }, | |
| }), | |
| { | |
| status: 400, | |
| headers: { "content-type": "application/json" }, | |
| }, | |
| ), | |
| }); | |
| (async () => { | |
| let caught; | |
| try { | |
| await client.models.list(); | |
| } catch (error) { | |
| caught = error; | |
| } | |
| assert.ok(caught instanceof APIError); | |
| })().catch((error) => { | |
| console.error(error); | |
| process.exitCode = 1; | |
| }); | |
| EOF |