diff --git a/.github/scripts/audit-public-package.mjs b/.github/scripts/audit-public-package.mjs new file mode 100644 index 0000000..a7c5788 --- /dev/null +++ b/.github/scripts/audit-public-package.mjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const [archive, packageRoot] = process.argv.slice(2); +assert(archive?.endsWith(".tgz"), "provide the built npm tarball"); +assert(packageRoot, "provide the SDK package root"); + +const requireFromSdk = createRequire(path.resolve(packageRoot, "package.json")); +const { fromBinary } = await import(requireFromSdk.resolve("@bufbuild/protobuf")); +const { FileDescriptorProtoSchema } = await import(requireFromSdk.resolve("@bufbuild/protobuf/wkt")); +const temporary = mkdtempSync(path.join(tmpdir(), "deixic-public-audit-")); + +function filesBelow(directory) { + return readdirSync(directory).flatMap((name) => { + const absolute = path.join(directory, name); + return statSync(absolute).isDirectory() ? filesBelow(absolute) : [absolute]; + }); +} + +try { + const unpack = spawnSync("tar", ["-xzf", path.resolve(archive), "-C", temporary], { encoding: "utf8" }); + assert.equal(unpack.status, 0, unpack.stderr || "cannot unpack npm tarball"); + + const root = path.join(temporary, "package"); + const files = filesBelow(root); + const relative = files.map((file) => path.relative(root, file)); + assert(relative.includes("package.json"), "tarball is missing package.json"); + assert(relative.includes("dist/sdk/deixic/typescript/src/protocol.js"), "tarball is missing the public protocol"); + assert(relative.every((file) => !file.endsWith(".map")), "source map in public tarball"); + assert(relative.every((file) => !/\.(proto|ya?ml|openapi\.json)$/i.test(file)), "raw schema in public tarball"); + + const manifest = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8")); + assert.equal(manifest.name, "@evalops/deixic-sdk"); + assert.equal(manifest.repository?.url, "git+https://github.com/dx-corp/deixic-node.git"); + assert.deepEqual(Object.keys(manifest.dependencies ?? {}).sort(), [ + "@bufbuild/protobuf", "@connectrpc/connect", "@connectrpc/connect-web", + ]); + + const descriptors = []; + const internal = /\b(?:agentruntime|remoterunner|toolexecution|orbcontrol|evalops_platform)\b|\b(?:console|connectors|memory|meter|objectives|vfs)\.v1\b/i; + for (const file of files) { + const code = readFileSync(file, "utf8"); + assert(!internal.test(code), `internal namespace in ${path.relative(root, file)}`); + if (!file.endsWith(".js")) continue; + assert(!code.includes("sourceMappingURL="), `source map reference in ${path.relative(root, file)}`); + const calls = code.match(/\bfileDesc\(/g) ?? []; + const matches = [...code.matchAll(/\bfileDesc\(\s*["']([A-Za-z0-9+/=]+)["']\s*,\s*\[([^\]]*)\]/g)]; + assert.equal(matches.length, calls.length, `unrecognized descriptor encoding in ${path.relative(root, file)}`); + for (const match of matches) { + assert(code.includes('from "@bufbuild/protobuf/wkt"'), "unexpected descriptor import source"); + descriptors.push({ + proto: fromBinary(FileDescriptorProtoSchema, Buffer.from(match[1], "base64")), + imports: match[2].split(",").map((value) => value.trim()).filter(Boolean), + }); + } + } + assert.equal(descriptors.length, 1, "public package must contain exactly one embedded protobuf descriptor"); + const [{ proto: descriptor, imports }] = descriptors; + assert.equal(descriptor.name, "deixicpublic/v1/sdk.proto"); + assert.equal(descriptor.package, "deixicpublic.v1"); + assert.deepEqual(descriptor.dependency, []); + assert.deepEqual(imports, ["file_google_protobuf_timestamp"]); + assert.deepEqual(descriptor.service.map((service) => service.name), ["DeixicPublicService"]); + + console.log(JSON.stringify({ + artifact: archive, + files: relative.length, + descriptor: descriptor.name, + package: descriptor.package, + dependencies: ["google/protobuf/timestamp.proto"], + services: descriptor.service.map((service) => service.name), + })); +} finally { + rmSync(temporary, { recursive: true, force: true }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 802cf0c..1552cda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -106,3 +106,13 @@ jobs: npm run typecheck npm test npm run check:package + + - name: Audit packed public descriptor closure + if: steps.source.outputs.present == 'true' + shell: bash + working-directory: sdk/deixic/typescript + run: | + set -euo pipefail + npm pack --ignore-scripts --json > "$RUNNER_TEMP/deixic-pack.json" + file="$(jq -r '.[0].filename' "$RUNNER_TEMP/deixic-pack.json")" + node ../../../.github/scripts/audit-public-package.mjs "$file" . diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..c8b3aa7 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,108 @@ +name: Publish Deixic Node SDK + +on: + workflow_dispatch: + inputs: + version: + description: New MAJOR.MINOR.PATCH version to publish + required: true + type: string + +permissions: + contents: read + +concurrency: + group: deixic-node-publish-${{ inputs.version }} + cancel-in-progress: false + +jobs: + publish: + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-24.04 + timeout-minutes: 25 + permissions: + contents: read + id-token: write + steps: + - name: Check out public repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Verify projected source and version + env: + SDK_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + [[ "$SDK_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + python3 - <<'PY' + import json + from pathlib import Path + + receipt = json.loads(Path('.repository-projection.json').read_text()) + assert receipt['projection'] == 'deixic-node' + assert receipt['sourceRepository'] == 'dx-corp/mono' + assert receipt['destinationRepository'] == 'dx-corp/deixic-node' + assert receipt['publicationEligible'] is True + print(f"Projected Mono source: {receipt['sourceSha']}") + PY + node -e 'const p=require("./sdk/deixic/typescript/package.json"); if(p.repository?.url!=="git+https://github.com/dx-corp/deixic-node.git") process.exit(1)' + + - name: Set up Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + registry-url: https://registry.npmjs.org + package-manager-cache: false + + - name: Install npm 11 and package dependencies + working-directory: sdk/deixic/typescript + run: | + npm install --global npm@11 + npm ci --ignore-scripts --no-audit --no-fund + + - name: Build and pack exact release artifact + id: pack + working-directory: sdk/deixic/typescript + env: + SDK_VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + npm pkg set "version=$SDK_VERSION" + npm run check:package + npm pack --ignore-scripts --json > "$RUNNER_TEMP/deixic-pack.json" + file="$(jq -r '.[0].filename' "$RUNNER_TEMP/deixic-pack.json")" + integrity="$(jq -r '.[0].integrity' "$RUNNER_TEMP/deixic-pack.json")" + echo "tarball=$GITHUB_WORKSPACE/sdk/deixic/typescript/$file" >> "$GITHUB_OUTPUT" + echo "integrity=$integrity" >> "$GITHUB_OUTPUT" + + - name: Audit built public descriptor closure + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + run: node .github/scripts/audit-public-package.mjs "$TARBALL" sdk/deixic/typescript + + - name: Publish through npm trusted publisher + env: + TARBALL: ${{ steps.pack.outputs.tarball }} + run: npm publish "$TARBALL" --ignore-scripts --access public + + - name: Install and audit registry artifact + env: + SDK_VERSION: ${{ inputs.version }} + BUILT_INTEGRITY: ${{ steps.pack.outputs.integrity }} + run: | + set -euo pipefail + cd "$RUNNER_TEMP" + for _ in $(seq 1 120); do + if npm view "@evalops/deixic-sdk@$SDK_VERSION" dist.integrity --json > registry-integrity.json 2>/dev/null; then + break + fi + sleep 5 + done + test "$(jq -r . registry-integrity.json)" = "$BUILT_INTEGRITY" + npm pack "@evalops/deixic-sdk@$SDK_VERSION" --json > registry-pack.json + file="$(jq -r '.[0].filename' registry-pack.json)" + node "$GITHUB_WORKSPACE/.github/scripts/audit-public-package.mjs" "$RUNNER_TEMP/$file" "$GITHUB_WORKSPACE/sdk/deixic/typescript" + mkdir install && cd install + npm init --yes >/dev/null + npm install --ignore-scripts --no-audit --no-fund "@evalops/deixic-sdk@$SDK_VERSION" + node --input-type=module -e 'const sdk = await import("@evalops/deixic-sdk"); if (typeof sdk.createDeixicClient !== "function") process.exit(1)' + echo "Published @evalops/deixic-sdk@$SDK_VERSION from $GITHUB_SHA with integrity $BUILT_INTEGRITY" >> "$GITHUB_STEP_SUMMARY"