From 98516cedfc7b64410530ff21d00fef00c813d8cb Mon Sep 17 00:00:00 2001 From: Jack Chen Date: Fri, 4 Sep 2026 01:01:09 +0800 Subject: [PATCH] chore: pin the internal dependency tree and audit the production one (#570) A library that gets installed into someone else's backend is judged partly on what its own install pulls down. Three changes tighten that, and two questions turned out to need no change at all. The line this draws is what ships. Every dependency that stays inside the repository is now pinned to the exact version the lockfile already resolved: all 27 devDependencies across the four workspaces, plus the private release bot's own zod. The lockfile change is 28 spec lines with no resolution moved, no entry added or removed, and all 61 platform-specific entries intact. A new root .npmrc sets save-exact=true so the next one arrives pinned rather than as a range nobody notices. What a consumer installs keeps its range: `dependencies` in packages/core, the only published runtime block here, every `peerDependencies`, and otel's core compatibility range. An exact version in those blocks overrides the consumer's own resolution and stops their `npm audit fix` from reaching a patched build until we cut a release, which is a worse trade than the install-time drift it prevents. save-exact will still pin a new dependency added to one of them, so the package job now asserts all three published manifests and fails when one is left exact, next to the assertion that already covers otel's range. min-release-age is deliberately not set. It first shipped in npm 11.10.0, and the CI matrix runs Node 20 and 22, which bundle npm 10.8.2 and 10.9.8. Those two accept the key, echo it back from `npm config get`, and never act on it, so a quarantine window would hold on one leg of the matrix while silently doing nothing on the other two. It also has no effect on `npm ci`, which installs the lockfile verbatim. Every `npm ci` that installs this repository's own tree now passes --ignore-scripts, so a compromised dependency cannot run an install hook on a runner that holds release credentials. Only three dependencies declare a postinstall at all, and the full lint, test, build, scaffold, and example suites pass on a tree installed that way. The `npm install` calls that stand in for a real consumer are deliberately left alone: their job is to reproduce what a user's install actually does, and hardening them would make the smoke tests prove less than they claim. A new scheduled Supply chain audit workflow asks two questions about the dependencies that reach a consumer, an hour before the weekly release bot so a finding lands before the release PR is drafted. --omit=dev is the point of running it separately: auditing the full tree reports eighteen advisories, every one of them in test and fixture tooling that never ships, and a permanently red job is one nobody reads. The two steps fail differently. `npm audit` reports into the run summary, because a new upstream advisory is not this repository misbehaving and may have no fixed version yet. `npm audit signatures` fails, because it compares served bytes against the registry's own signature, so a mismatch is tampering rather than churn; all 27 production packages verify today. Two things needed no change. npm provenance is already live: publish.yml publishes through npm trusted publishing with id-token: write, which attaches SLSA attestations on its own, and every version since core 1.16.0, otel 0.1.2, and create-oma-app 0.8.1 carries one. And .npmrc left .gitignore in #198 because it only restated the default registry; it comes back tracked, with the ignore rule narrowed so a contributor's own nested .npmrc stays out. --- .github/CONTRIBUTING.md | 1 + .github/workflows/ci.yml | 32 +++++- .github/workflows/provider-canary.yml | 2 +- .github/workflows/publish.yml | 2 +- .github/workflows/release-bot.yml | 2 +- .github/workflows/supply-chain-audit.yml | 98 +++++++++++++++++++ .gitignore | 3 + .npmrc | 30 ++++++ AGENTS.md | 1 + package-lock.json | 56 +++++------ package.json | 4 +- packages/core/package.json | 24 ++--- packages/create-oma-app/package.json | 6 +- packages/otel/package.json | 14 +-- packages/release-bot/package.json | 8 +- .../tests/package-boundary.test.ts | 2 +- 16 files changed, 220 insertions(+), 65 deletions(-) create mode 100644 .github/workflows/supply-chain-audit.yml create mode 100644 .npmrc diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 98e82ee..ffca6b8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -64,6 +64,7 @@ Opening a PR prefills the [pull request template](pull_request_template.md), whi - TypeScript strict mode, ES modules (`.js` extensions in imports) - No additional linter/formatter configured — follow existing patterns - Keep dependency ownership explicit. Core must remain importable and runnable without optional integrations +- Pin what stays in the repository; keep ranges on what a consumer installs. Every `devDependencies` entry is pinned to an exact version and the root [`.npmrc`](../.npmrc) sets `save-exact=true`, but core's `dependencies`, every `peerDependencies`, and otel's `@open-multi-agent/core` stay semver ranges. `save-exact` will pin a new dependency added to one of those blocks, so widen it by hand — CI fails when one is left exact - Add new optional provider SDKs as peer dependencies and load them lazily with dynamic `import()` - Keep OpenTelemetry APIs, SDKs, semantic-convention packages, and exporters in `@open-multi-agent/otel`, never in the core root import - Version `@open-multi-agent/otel` independently from core and express core compatibility in its dependency range diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9f034c..6189bc5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - run: npm run lint test: @@ -38,7 +38,7 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - run: npm test coverage: @@ -53,7 +53,7 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - run: npm run test:coverage - uses: codecov/codecov-action@v5 with: @@ -70,7 +70,7 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - run: npm run build - name: Smoke-test that the built entry points import and the CLI runs working-directory: packages/core @@ -112,6 +112,28 @@ jobs: exit 1 fi done + - name: Assert the published dependency blocks stay semver ranges + run: | + set -euo pipefail + # save-exact=true in the root .npmrc pins whatever `npm install` + # saves. That is right for the tree that never leaves this + # repository and wrong for the blocks a consumer installs: an exact + # version there overrides their own resolution and stops their + # `npm audit fix` from reaching a patched build until we cut a + # release. A new dependency added to one of these has to be widened + # by hand, so assert it rather than trusting the habit. + fail=0 + for manifest in packages/core/package.json packages/otel/package.json packages/create-oma-app/package.json; do + for block in dependencies peerDependencies; do + pinned=$(jq -r --arg b "$block" '(.[$b] // {}) | to_entries[] | select(.value | test("^[0-9]")) | "\(.key)@\(.value)"' "$manifest" | tr '\n' ' ') + if [ -n "$pinned" ]; then + echo "::error::$manifest .$block ships to consumers and must stay semver ranges, but these are exact: $pinned" + fail=1 + fi + done + done + test "$fail" -eq 0 + - name: Typecheck the create-oma-app template against core run: npm run typecheck:template -w create-oma-app - name: Assert the npm tarball ships exactly the expected files @@ -232,6 +254,6 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - name: End-to-end scaffold smoke test (pack -> generate -> install -> run gate) run: npm run test:scaffold diff --git a/.github/workflows/provider-canary.yml b/.github/workflows/provider-canary.yml index 896eb11..a14fdf9 100644 --- a/.github/workflows/provider-canary.yml +++ b/.github/workflows/provider-canary.yml @@ -22,7 +22,7 @@ jobs: with: node-version: 22 cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - name: Require the DeepSeek canary credential env: DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d6ce667..d5a7beb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -103,7 +103,7 @@ jobs: node-version: 24 registry-url: https://registry.npmjs.org - - run: npm ci + - run: npm ci --ignore-scripts - run: npm run build - name: Publish missing packages, tag, and create the GitHub Release diff --git a/.github/workflows/release-bot.yml b/.github/workflows/release-bot.yml index 478fa6a..0bfd40c 100644 --- a/.github/workflows/release-bot.yml +++ b/.github/workflows/release-bot.yml @@ -56,7 +56,7 @@ jobs: node-version: 24 cache: npm - - run: npm ci + - run: npm ci --ignore-scripts - name: Configure auditable bot commit identity env: diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml new file mode 100644 index 0000000..0ddaa18 --- /dev/null +++ b/.github/workflows/supply-chain-audit.yml @@ -0,0 +1,98 @@ +name: Supply chain audit + +# Two questions about the dependencies that actually reach a consumer of the +# published packages, asked on a schedule rather than on every pull request. +# +# `--omit=dev` is the whole point of running this separately. A full audit of +# this repository reports well over a dozen advisories, every one of them in +# the development tree (test runner, provider SDK fixtures, scaffolder +# tooling) where nothing ships to npm. Auditing that tree on a schedule would +# produce a permanently red job that everyone learns to ignore. The production +# tree is small enough that a finding in it is worth reading. +# +# The two steps fail differently on purpose: +# +# - `npm audit` REPORTS. A new advisory upstream is not this repository +# misbehaving, and there may be no fixed version to move to yet. The +# finding lands in the run summary; a maintainer decides what it means. +# - `npm audit signatures` FAILS. It compares the bytes npm served against +# the registry's own signature, so a mismatch is a tampering or corruption +# signal rather than upstream churn. Every production package verifies +# today, so this starts green and only turns red when something is wrong. +# +# Neither step gates a pull request. Scheduling this an hour before the weekly +# release bot means a finding is on the record before the release PR is drafted. + +on: + workflow_dispatch: + schedule: + # Friday 09:41 UTC, an hour ahead of the release bot's 10:23. Off the top + # of the hour for the same reason release-bot.yml documents: the most + # contended slot is the one GitHub is most likely to drop. + - cron: '41 9 * * 5' + +permissions: + contents: read + +concurrency: + group: supply-chain-audit-${{ github.ref }} + cancel-in-progress: false + +jobs: + audit: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci --ignore-scripts + + - name: Report advisories against the production dependency tree + run: | + set -uo pipefail + npm audit --omit=dev --json > "$RUNNER_TEMP/audit.json" + audit_exit=$? + npm audit --omit=dev > "$RUNNER_TEMP/audit.txt" 2>&1 || true + cat "$RUNNER_TEMP/audit.txt" + + # A non-zero exit means either "advisories found" or "the audit could + # not run", and only the second one is this workflow's problem. A + # registry failure still writes well-formed JSON, so check for the + # metadata block rather than trusting the exit code or the parse. + if ! node -e 'const r = JSON.parse(require("node:fs").readFileSync(process.argv[1], "utf8")); if (r.error || !r.metadata?.vulnerabilities) process.exit(1)' "$RUNNER_TEMP/audit.json"; then + echo "::error::npm audit did not produce a report (exit $audit_exit); the production tree was not actually checked" + exit 1 + fi + + node - "$RUNNER_TEMP/audit.json" "$RUNNER_TEMP/audit.txt" <<'REPORT' >> "$GITHUB_STEP_SUMMARY" + const fs = require('node:fs') + const report = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) + const counts = report.metadata?.vulnerabilities ?? {} + const total = counts.total ?? 0 + + console.log('## Production dependency advisories\n') + console.log(`Audited \`${report.metadata?.dependencies?.prod ?? '?'}\` production dependencies with \`npm audit --omit=dev\`.\n`) + + if (total === 0) { + console.log('No advisories against the dependencies that reach a consumer of the published packages.') + } else { + console.log('| Severity | Count |') + console.log('|---|---|') + for (const level of ['critical', 'high', 'moderate', 'low', 'info']) { + if (counts[level]) console.log(`| ${level} | ${counts[level]} |`) + } + console.log('') + console.log('
Full report\n') + console.log('```') + console.log(fs.readFileSync(process.argv[3], 'utf8').trimEnd()) + console.log('```\n') + console.log('
\n') + console.log('This step reports rather than fails. Decide whether the advisory reaches a caller of the published packages before acting on it.') + } + REPORT + + - name: Verify registry signatures and provenance attestations + run: npm audit signatures --omit=dev diff --git a/.gitignore b/.gitignore index a12b8e2..a597493 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,10 @@ oma-dashboards/ .agent-workspace/ .github/brand/frames/ .claude/ +# Local npm configuration, which may carry registry credentials. The +# repository's own root .npmrc is tracked and is the one exception. .npmrc +!/.npmrc .env # Benchmark output. The harness in bench/src/ is tracked; everything it diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..e2e7159 --- /dev/null +++ b/.npmrc @@ -0,0 +1,30 @@ +# Repository-level npm configuration. It applies to every workspace and to +# contributors and CI alike. + +# Save an exact version rather than a caret range when `npm install ` +# adds or upgrades a dependency, so an upgrade is always a reviewable diff +# instead of something a later install resolves on its own. +# +# The line this draws is what ships. Anything that stays inside the repository +# is pinned: every `devDependencies` entry, and the dependencies of the +# private release bot. Anything a consumer installs keeps a range, because an +# exact version there overrides their resolution and stops their own +# `npm audit fix` from reaching a patched build without waiting for a release: +# +# - `dependencies` in packages/core/package.json, the only published +# runtime block in the repository +# - every `peerDependencies` entry, which states what a consumer may bring +# - `@open-multi-agent/core` in packages/otel/package.json, which expresses +# core compatibility +# +# save-exact does not rewrite manifests that already carry a range, but it +# will pin a NEW dependency added to one of those blocks, so widen it by hand. +# The `package` job in ci.yml asserts both published blocks and fails if +# either is ever saved exactly. +save-exact=true + +# `min-release-age` is deliberately absent. It first shipped in npm 11.10.0, +# and the CI matrix runs Node 20 and 22, which bundle npm 10.8.2 and 10.9.8. +# Those two would ignore the key without a word, so the quarantine window +# would hold on one leg of the matrix and not the other two. It also has no +# effect on `npm ci`, which installs the lockfile verbatim. diff --git a/AGENTS.md b/AGENTS.md index 9b1468a..9f4d0a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,7 @@ Examples and core E2E tests may require real provider credentials. Unit tests mo - Change source files, tests, templates, or docs rather than generated `dist/` output. - Add or update tests for behavior changes. Update user-facing docs and examples when public behavior changes, or state why they are not applicable. - Keep dependency ownership explicit. Core must remain importable and runnable without optional integrations. +- **Pin what stays in the repository; keep ranges on what a consumer installs.** Every `devDependencies` entry and the private release bot's dependencies are pinned exactly, and the root [`.npmrc`](.npmrc) sets `save-exact=true` so a new one arrives pinned. The published blocks stay semver ranges — core's `dependencies`, every `peerDependencies`, and otel's `@open-multi-agent/core` — because an exact version there overrides a consumer's own resolution and blocks their `npm audit fix` until we cut a release. `save-exact` will pin a new dependency added to one of those blocks, so widen it by hand; the `package` job in [`.github/workflows/ci.yml`](.github/workflows/ci.yml) fails when one is left exact. - Add optional provider SDKs as peer dependencies and load them lazily with dynamic `import()`. Do not maintain a fixed dependency or adapter count in documentation. - OpenTelemetry APIs, SDKs, semantic-convention packages, and exporters belong in `@open-multi-agent/otel`, never in the core root import. The application owns its tracer/provider lifecycle unless an API explicitly says otherwise. - Treat `docs/` as the source of truth for subsystem behavior. Keep this file to rules and concise invariants; link to docs instead of copying long explanations. diff --git a/package-lock.json b/package-lock.json index a538ba4..a6fa995 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "packages/*" ], "devDependencies": { - "tsx": "^4.21.0", - "typescript": "^5.6.0" + "tsx": "4.21.0", + "typescript": "5.9.3" }, "engines": { "node": ">=20.0.0" @@ -5354,18 +5354,18 @@ "oma": "dist/cli/oma.js" }, "devDependencies": { - "@agentclientprotocol/sdk": "^1.2.1", - "@ai-sdk/openai": "^4.0.0", - "@ai-sdk/provider": "^4.0.0", - "@aws-sdk/client-bedrock-runtime": "^3.1040.0", - "@google/genai": "^1.48.0", - "@modelcontextprotocol/sdk": "^1.18.0", - "@types/node": "^22.0.0", - "@vitest/coverage-v8": "^2.1.9", - "ai": "^7.0.0", - "tsx": "^4.21.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@agentclientprotocol/sdk": "1.2.1", + "@ai-sdk/openai": "4.0.27", + "@ai-sdk/provider": "4.0.4", + "@aws-sdk/client-bedrock-runtime": "3.1101.0", + "@google/genai": "1.48.0", + "@modelcontextprotocol/sdk": "1.29.0", + "@types/node": "22.19.15", + "@vitest/coverage-v8": "2.1.9", + "ai": "7.0.47", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "2.1.9" }, "engines": { "node": ">=20.0.0" @@ -5432,9 +5432,9 @@ "create-oma-app": "dist/index.js" }, "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@types/node": "22.19.15", + "typescript": "5.9.3", + "vitest": "2.1.9" }, "engines": { "node": ">=20.0.0" @@ -5448,13 +5448,13 @@ "@open-multi-agent/core": "^1.11.0" }, "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", - "@opentelemetry/sdk-trace-base": "^2.9.0", - "@opentelemetry/sdk-trace-node": "^2.10.0", - "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@opentelemetry/api": "1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "0.221.0", + "@opentelemetry/sdk-trace-base": "2.10.0", + "@opentelemetry/sdk-trace-node": "2.10.0", + "@types/node": "22.19.15", + "typescript": "5.9.3", + "vitest": "2.1.9" }, "engines": { "node": ">=20.0.0" @@ -5468,15 +5468,15 @@ "version": "0.0.0", "dependencies": { "@open-multi-agent/core": "*", - "zod": "^3.23.0" + "zod": "3.25.76" }, "bin": { "oma-release-bot": "dist/cli.js" }, "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@types/node": "22.19.15", + "typescript": "5.9.3", + "vitest": "2.1.9" }, "engines": { "node": ">=20.0.0" diff --git a/package.json b/package.json index 31a755e..c9b9e78 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "node": ">=20.0.0" }, "devDependencies": { - "tsx": "^4.21.0", - "typescript": "^5.6.0" + "tsx": "4.21.0", + "typescript": "5.9.3" } } diff --git a/packages/core/package.json b/packages/core/package.json index c7235ec..9f2c6ac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -134,17 +134,17 @@ } }, "devDependencies": { - "@agentclientprotocol/sdk": "^1.2.1", - "@ai-sdk/openai": "^4.0.0", - "@ai-sdk/provider": "^4.0.0", - "@aws-sdk/client-bedrock-runtime": "^3.1040.0", - "@google/genai": "^1.48.0", - "@modelcontextprotocol/sdk": "^1.18.0", - "@types/node": "^22.0.0", - "@vitest/coverage-v8": "^2.1.9", - "ai": "^7.0.0", - "tsx": "^4.21.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@agentclientprotocol/sdk": "1.2.1", + "@ai-sdk/openai": "4.0.27", + "@ai-sdk/provider": "4.0.4", + "@aws-sdk/client-bedrock-runtime": "3.1101.0", + "@google/genai": "1.48.0", + "@modelcontextprotocol/sdk": "1.29.0", + "@types/node": "22.19.15", + "@vitest/coverage-v8": "2.1.9", + "ai": "7.0.47", + "tsx": "4.21.0", + "typescript": "5.9.3", + "vitest": "2.1.9" } } diff --git a/packages/create-oma-app/package.json b/packages/create-oma-app/package.json index 7b79dc3..06f7ae3 100644 --- a/packages/create-oma-app/package.json +++ b/packages/create-oma-app/package.json @@ -56,8 +56,8 @@ "node": ">=20.0.0" }, "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@types/node": "22.19.15", + "typescript": "5.9.3", + "vitest": "2.1.9" } } diff --git a/packages/otel/package.json b/packages/otel/package.json index 155612f..260cd1c 100644 --- a/packages/otel/package.json +++ b/packages/otel/package.json @@ -58,12 +58,12 @@ "@opentelemetry/api": "^1.9.0" }, "devDependencies": { - "@opentelemetry/api": "^1.9.0", - "@opentelemetry/exporter-trace-otlp-http": "^0.221.0", - "@opentelemetry/sdk-trace-base": "^2.9.0", - "@opentelemetry/sdk-trace-node": "^2.10.0", - "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@opentelemetry/api": "1.9.1", + "@opentelemetry/exporter-trace-otlp-http": "0.221.0", + "@opentelemetry/sdk-trace-base": "2.10.0", + "@opentelemetry/sdk-trace-node": "2.10.0", + "@types/node": "22.19.15", + "typescript": "5.9.3", + "vitest": "2.1.9" } } diff --git a/packages/release-bot/package.json b/packages/release-bot/package.json index 87422f5..d9d6db0 100644 --- a/packages/release-bot/package.json +++ b/packages/release-bot/package.json @@ -21,11 +21,11 @@ }, "dependencies": { "@open-multi-agent/core": "*", - "zod": "^3.23.0" + "zod": "3.25.76" }, "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" + "@types/node": "22.19.15", + "typescript": "5.9.3", + "vitest": "2.1.9" } } diff --git a/packages/release-bot/tests/package-boundary.test.ts b/packages/release-bot/tests/package-boundary.test.ts index 4711fa6..1906800 100644 --- a/packages/release-bot/tests/package-boundary.test.ts +++ b/packages/release-bot/tests/package-boundary.test.ts @@ -15,7 +15,7 @@ describe('release bot package boundary', () => { expect(manifest.publishConfig).toBeUndefined() expect(manifest.dependencies).toEqual({ '@open-multi-agent/core': '*', - zod: '^3.23.0', + zod: '3.25.76', }) }) })