diff --git a/.github/release-notes.md b/.github/release-notes.md index 16dd0ca8d..1942b4580 100644 --- a/.github/release-notes.md +++ b/.github/release-notes.md @@ -1,74 +1,30 @@ - + ## Highlights -- Preserve confirmed finding identities across scans and comparisons, and show - related findings with their reasons while keeping distinct findings separate. - Large comparisons now use bounded batches without truncating finding text; - inputs that cannot fit leave matching explicitly incomplete. -- Improve deduplication with separate screening and pair reviews, validated - pair assignments, and groups that respect explicit `DISTINCT` decisions. - Invalid submissions receive one corrective turn; blocked reviews fail without - recording a verdict. `DeduplicationReviewError` exposes structured, sanitized - failure details. The SDK also adds `deduplicateScanDirectory` for complete, - sealed scans outside local history. -- Generate synthetic Standard scan results with `scan --mock` or the SDK's - `mock: true`, without authentication or model calls. Mock results support - normal reports, exports, history, and reruns. See - [mock scans](https://github.com/openai/codex-security/blob/npm-v0.1.25/sdk/typescript/README.md#generate-mock-scan-results). -- Increase a running scan's total budget from the interactive dashboard when - usage reaches 80% of its limit, or use the SDK's `onBudgetApproaching` - callback. The existing limit remains enforced until an increase is saved. - See [scan cost limits](https://github.com/openai/codex-security/blob/npm-v0.1.25/sdk/typescript/README.md#progress-and-cost). -- Recognize existing Codex authentication in CLI and SDK login status. SDK - scans, comparisons, and deduplication reviews now honor native command-auth - providers, including renewable tokens. -- Configure the findings service's full embeddings endpoint with - `CODEX_SECURITY_EMBEDDINGS_URL`. The new `@openai/codex-security/server` - exports support embedding credentials supplied by a callback before each - HTTP batch. See - [embeddings and storage](https://github.com/openai/codex-security/blob/npm-v0.1.25/sdk/typescript/README.md#embeddings-and-storage). -- Include PowerShell module (`.psm1`) and data (`.psd1`) files in scan - inventories, and recognize BOM-marked UTF-16 source files as text. -- Preserve scoped scan and component-plan inventories after directory renames - that change only letter casing on case-insensitive filesystems. -- Support long Codex executable paths on Windows, including nested Deep Scan - workers, and retry credential snapshots for another `Get-Acl` path-not-found - race when a descendant disappears during inspection. -- Honor case-insensitive Windows environment variable names during finding - deduplication, so configured API credentials and private configuration paths - are used consistently. -- Stream tracked binary diffs when hashing repository snapshots, reducing - memory use while preserving the existing digest format. -- Include complete OCI metadata in container image labels and multiarchitecture - annotations, with documentation pinned to the source commit and image - verification commands in the release workflow summary. See - [container metadata and verification](https://github.com/openai/codex-security/blob/npm-v0.1.25/docker/README.md#image-metadata-and-verification). +- Use one Codex Security release version across the CLI, TypeScript SDK, + bundled plugin, and MCP server. Every product release now advances the plugin + version so cached plugin installations recognize the upgrade. The findings + service and dashboards continue to ship in the same package and image. +- Require container releases to use the same source commit as the matching npm + release tag. An identical package version on a later commit no longer counts + as the same release. +- Recognize BOM-marked UTF-16 source files and PowerShell module and data files + in scan inventories. +- Improve Windows handling of long Codex executable paths, case-only renames, + and dedupe environment settings. ## Upgrade notes -- Mock mode is opt-in and saves clearly marked synthetic findings in local - history; use a separate `CODEX_SECURITY_STATE_DIR` for disposable test data. - It supports Standard scans only and does not audit the repository. -- Interactive budget increases are unavailable in CI, JSON/JSONL, headless, - and verbose modes. Existing cost limits continue to apply in those modes. -- The embeddings URL defaults to the existing OpenAI endpoint. A configured - endpoint receives finding inputs and the bearer credential and must support - the OpenAI embeddings format. Embeddings credentials remain separate from - Codex ChatGPT sign-in. -- Local history applies an automatic database index migration. Completed scan - artifacts remain unchanged. -- Source builds now use repository-pinned pnpm 11.19.0, including MCP app - dependencies, whose configuration requires a seven-day minimum release age. - From the repository root, run - `pnpm --dir plugins/codex-security/mcp-app install --frozen-lockfile`. - See - [running without Docker](https://github.com/openai/codex-security/blob/npm-v0.1.25/sdk/typescript/README.md#running-without-docker). -- Container publication remains separate from npm publication. Existing stable - container tags are not updated in place. - -Build and CI updates also improve package verification, portable Python checks, -Windows fixtures, and test scheduling. Documentation clarifies portable -environment-variable guidance and safe examples. +- SDK consumers using a range such as `^0.1.24` must explicitly update to + `^0.2.0`. Existing CLI commands and public SDK version fields remain available. +- Version alignment does not change artifact schemas or database migration + versions. Saved scans keep their original producer versions, and custom + plugins continue to report their own versions. Codex runtime dependencies and + external plugin catalogs retain their independent versions. +- Publish a container from `container-v0.2.0` at the same commit as + `npm-v0.2.0`. Manual publication from `main` works only while `main` points to + that release commit. See + [container publishing](https://github.com/openai/codex-security/blob/npm-v0.2.0/docker/README.md#publishing). The categorized list below contains the individual changes. diff --git a/.github/workflows/container-release.yml b/.github/workflows/container-release.yml index 61ee8d4a9..ac8921844 100644 --- a/.github/workflows/container-release.yml +++ b/.github/workflows/container-release.yml @@ -291,6 +291,8 @@ jobs: exit 1 fi + sh docker/verify-container-release-source.sh "$version" "$GITHUB_SHA" + printf 'image=ghcr.io/%s\n' "${GITHUB_REPOSITORY_OWNER,,}/codex-security" >> "$GITHUB_OUTPUT" printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" diff --git a/RELEASING.md b/RELEASING.md index 5bc11fba5..4177e9e45 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,6 +7,19 @@ Historical releases may contain generated notes only. The release tag and npm package use the same stable version: `npm-vX.Y.Z` and `@openai/codex-security@X.Y.Z`. +Starting with `0.2.0`, the CLI, TypeScript SDK, bundled plugin, and MCP server +share one release version. The findings service and dashboards ship in that +same package and container image. `sdk/typescript/package.json` is the version +source; the plugin manifest, private MCP package, and Python test-tooling +metadata are synchronized copies. The Python metadata does not represent a +separate published SDK. + +Keep Codex runtime dependencies, artifact schemas, database migrations, and +test fixture versions independent. Existing public version fields remain +available, and a custom plugin still reports its own producer version. External +Codex catalogs control their own publication versions. Historical releases and +saved scan artifacts retain their original versions. + ## Pull request titles and categories Pull request titles must follow this form: @@ -160,7 +173,11 @@ Generated PRs leave the disclosure attestations unchecked for maintainer review. ## Prepare a release 1. Choose the next stable version and update `sdk/typescript/package.json`. - Keep the lockfile version in sync when it records the package version. + Run `pnpm --dir sdk/typescript run sync:versions` to update the component + manifests. Keep the lockfile version in sync when it records the package + version. Every product release advances the bundled plugin version, including + releases without plugin changes, so cached plugin installations refresh on + upgrade. 2. Update `.github/release-notes.md`. Its first line must be `` with the exact package version. 3. Summarize the changes a user will notice. Call out required migration or @@ -172,6 +189,13 @@ Generated PRs leave the disclosure attestations unchecked for maintainer review. pull request. Do not merge until required CI, review, and public disclosure checks pass on the current commit. +`pnpm --dir sdk/typescript run check:versions` checks synchronization without +writing files. It also runs during `prepack`, including CI and Docker builds. +Package checks verify that the bundled plugin manifest matches the package; +the installed-package smoke check verifies the public SDK exports, CLI version, +and MCP server version. Do not synchronize dependency versions or rewrite +historical fixtures as part of a release bump. + Review the summary with the same standard as product documentation. Keep it specific, describe behavior before implementation, and do not include private repositories, systems, people, findings, links, or issue identifiers. @@ -197,6 +221,11 @@ do not prepare a release by editing or committing files there. Monitor all three workflows. A version bump is not a completed release until the npm package and GitHub release both exist and match the tag. +Container publication is a separate protected workflow. Create `container-vX.Y.Z` +at the same commit as `npm-vX.Y.Z`, or dispatch from `main` while it still points +to that exact release commit. Matching version strings on different commits are +not sufficient. See [container publishing](docker/README.md#publishing). + ## Verify Check the published state before announcing the release: diff --git a/docker/README.md b/docker/README.md index 50d9ce468..9e4909f5c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -82,9 +82,13 @@ and [package access settings](https://docs.github.com/en/packages/learn-github-p ## Publishing -After merging to `main`, push `container-v` matching the SDK package -version or run `container-release` manually on `main`. Releases require a commit -on protected `main`; pull requests only build and test. +After the npm release tag exists, create `container-v` at the same +commit as `npm-v` and push the container tag. Both tags must match the +SDK package version. You can also run `container-release` manually on `main` +while it still points to that exact npm release commit. If `main` has advanced, +use the matching container tag; a later commit with the same package version is +not the same release. Releases must remain on protected `main` history; pull +requests only build and test. If a release fails, fix the cause and rerun only failed jobs; do not overwrite an existing stable version. `bootstrap` and diff --git a/docker/verify-container-release-source.sh b/docker/verify-container-release-source.sh new file mode 100644 index 000000000..432f45e06 --- /dev/null +++ b/docker/verify-container-release-source.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +set -eu + +if [ "$#" -ne 2 ]; then + printf '%s\n' 'Usage: verify-container-release-source.sh VERSION COMMIT' >&2 + exit 2 +fi + +version=$1 +expected_commit=$2 +release_tag="npm-v$version" + +if ! release_commit="$(git rev-parse --verify "refs/tags/$release_tag^{commit}")"; then + printf '%s\n' "::error::Create the npm release $release_tag before publishing its container." >&2 + exit 1 +fi + +if [ "$release_commit" != "$expected_commit" ]; then + printf '%s\n' "::error::Container version $version must use the same commit as $release_tag. Create container-v$version at that release commit if main has advanced." >&2 + exit 1 +fi diff --git a/plugins/codex-security/.codex-plugin/plugin.json b/plugins/codex-security/.codex-plugin/plugin.json index 43fa0c2a9..775dfd117 100644 --- a/plugins/codex-security/.codex-plugin/plugin.json +++ b/plugins/codex-security/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codex-security", - "version": "0.1.94", + "version": "0.2.0", "description": "Codex Security workflows for security scans, analysis, and investigation.", "author": { "name": "OpenAI" diff --git a/plugins/codex-security/mcp-app/package.json b/plugins/codex-security/mcp-app/package.json index 6dfeb1cd7..b950a440c 100644 --- a/plugins/codex-security/mcp-app/package.json +++ b/plugins/codex-security/mcp-app/package.json @@ -1,6 +1,6 @@ { "name": "codex-security-mcp-app", - "version": "0.1.158", + "version": "0.2.0", "type": "module", "private": true, "scripts": { diff --git a/plugins/codex-security/pyproject.toml b/plugins/codex-security/pyproject.toml index 0c9942c80..90244760e 100644 --- a/plugins/codex-security/pyproject.toml +++ b/plugins/codex-security/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "codex-security" -version = "0.1.0" +version = "0.2.0" requires-python = ">=3.12" [project.optional-dependencies] diff --git a/sdk/typescript/package.json b/sdk/typescript/package.json index d04449aa7..09f9adea5 100644 --- a/sdk/typescript/package.json +++ b/sdk/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@openai/codex-security", - "version": "0.1.25", + "version": "0.2.0", "description": "TypeScript SDK and CLI for Codex Security", "license": "Apache-2.0", "author": "OpenAI", @@ -52,12 +52,14 @@ "build:plugin": "node scripts/build-plugin.mjs", "check:plugin-source": "node scripts/check-plugin-source.mjs", "check:package": "node scripts/check-package.mjs", + "check:versions": "node scripts/sync-versions.mjs --check", "format": "prettier --check --ignore-path .gitignore --ignore-path .prettierignore \"**/*.{cjs,mjs,js,ts,tsx,json,md}\"", "generate:models": "node scripts/generate-models.cjs", "generate:models:check": "node scripts/generate-models.cjs --check", "lint": "tsc --noEmit", - "prepack": "node --run build:plugin && node --run build", + "prepack": "node --run check:versions && node --run build:plugin && node --run build", "start:server": "node dist/server/index.js", + "sync:versions": "node scripts/sync-versions.mjs", "test": "node --run build:plugin && bun test --timeout 30000 ./tests-ts", "test:ci": "node -e \"require('node:fs').mkdirSync('reports',{recursive:true})\" && pnpm run test --coverage --coverage-reporter=text --coverage-reporter=lcov --reporter=junit --reporter-outfile=reports/junit.xml", "test:mcp": "node --run build:plugin && pnpm --dir ../../plugins/codex-security/mcp-app run test:mcp", diff --git a/sdk/typescript/scripts/check-package.mjs b/sdk/typescript/scripts/check-package.mjs index e66a29eea..a7f624887 100644 --- a/sdk/typescript/scripts/check-package.mjs +++ b/sdk/typescript/scripts/check-package.mjs @@ -301,6 +301,14 @@ if ( ) { throw new Error("npm package does not contain the expected public metadata."); } +const pluginManifest = JSON.parse( + archiveFile("package/_bundled_plugin/.codex-plugin/plugin.json").toString( + "utf8", + ), +); +if (pluginManifest.version !== packageJson.version) { + throw new Error("Bundled plugin version must match the npm package version."); +} assertExpectedGitHead( packageJson, process.env.CODEX_SECURITY_EXPECTED_GIT_HEAD, diff --git a/sdk/typescript/scripts/smoke-package.mjs b/sdk/typescript/scripts/smoke-package.mjs index de4ffed33..cae89d2ef 100644 --- a/sdk/typescript/scripts/smoke-package.mjs +++ b/sdk/typescript/scripts/smoke-package.mjs @@ -247,11 +247,17 @@ async function smokeNestedDeepScanWorker(installedRoot, consumer) { }); } assert.equal(initialized.status, 0, initialized.stderr); + const serverInfo = JSON.parse(initialized.stdout.trim()).result.serverInfo; assert.equal( - JSON.parse(initialized.stdout.trim()).result.serverInfo.name, + serverInfo.name, "codex-security", "The installed MCP launcher must initialize the bundled security server.", ); + assert.equal( + serverInfo.version, + packageManifest.version, + "The installed MCP server must report the package release version.", + ); const globalCodex = spawnSync("codex", ["--version"], { cwd: consumer, @@ -399,7 +405,15 @@ try { [ "--input-type=module", "--eval", - `const sdk = await import(${JSON.stringify(packageManifest.name)}); for (const name of ["CodexSecurity", "publishScan", "publishScanToCustom", "checkScanPublication", "deduplicateScan", "classifySeverity", "classifyScanSeverity", "classifyScanDirectorySeverity"]) if (typeof sdk[name] !== "function") throw new Error("The installed package does not export " + name + ".");`, + ` + import assert from "node:assert/strict"; + const sdk = await import(${JSON.stringify(packageManifest.name)}); + for (const name of ["CodexSecurity", "publishScan", "publishScanToCustom", "checkScanPublication", "deduplicateScan", "classifySeverity", "classifyScanSeverity", "classifyScanDirectorySeverity"]) { + assert.equal(typeof sdk[name], "function", "The installed package must export " + name + "."); + } + assert.equal(sdk.VERSION, ${JSON.stringify(packageManifest.version)}); + assert.equal(sdk.BUNDLED_PLUGIN_VERSION, sdk.VERSION); + `, ], { cwd: consumer }, ); diff --git a/sdk/typescript/scripts/sync-versions.mjs b/sdk/typescript/scripts/sync-versions.mjs new file mode 100644 index 000000000..98a3f98c9 --- /dev/null +++ b/sdk/typescript/scripts/sync-versions.mjs @@ -0,0 +1,78 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { releaseVersion } from "./release-automation.mjs"; + +const repositoryRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const componentManifests = [ + "plugins/codex-security/.codex-plugin/plugin.json", + "plugins/codex-security/mcp-app/package.json", + "plugins/codex-security/pyproject.toml", +]; + +export async function syncVersions({ + root = repositoryRoot, + check = false, +} = {}) { + const version = releaseVersion( + JSON.parse( + await readFile(join(root, "sdk/typescript/package.json"), "utf8"), + ), + ); + const updates = await Promise.all( + componentManifests.map(async (path) => { + const contents = await readFile(join(root, path), "utf8"); + const field = path.endsWith(".json") + ? /^([ \t]*"version"[ \t]*:[ \t]*")[^"]+("[ \t]*,?)/mu + : /^(version[ \t]*=[ \t]*")[^"]+(")/mu; + if (!field.test(contents)) { + throw new Error(`${path} must declare its component version.`); + } + return { + path, + contents, + updated: contents.replace( + field, + (_match, prefix, suffix) => `${prefix}${version}${suffix}`, + ), + }; + }), + ); + const changed = updates.filter( + ({ contents, updated }) => contents !== updated, + ); + if (check && changed.length > 0) { + throw new Error( + `Component versions must match ${version}:\n${changed.map(({ path }) => path).join("\n")}\nRun pnpm --dir sdk/typescript run sync:versions.`, + ); + } + if (!check) { + for (const { path, updated } of changed) { + await writeFile(join(root, path), updated); + } + } + return { version, changed: changed.map(({ path }) => path) }; +} + +const invokedPath = process.argv[1]; +if ( + invokedPath !== undefined && + pathToFileURL(resolve(invokedPath)).href === import.meta.url +) { + try { + const args = process.argv.slice(2); + if (args.length > 1 || (args.length === 1 && args[0] !== "--check")) { + throw new Error("Usage: node scripts/sync-versions.mjs [--check]"); + } + const check = args[0] === "--check"; + const { version, changed } = await syncVersions({ check }); + console.log( + check + ? `Verified component versions match ${version}.` + : `Synchronized ${changed.length} component manifests to ${version}.`, + ); + } catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } +} diff --git a/sdk/typescript/src/version.ts b/sdk/typescript/src/version.ts index 51643cf9b..bf4a1fca7 100644 --- a/sdk/typescript/src/version.ts +++ b/sdk/typescript/src/version.ts @@ -9,7 +9,7 @@ const PACKAGE_VERSIONS = packageVersions( export const VERSION = PACKAGE_VERSIONS.package; export const CODEX_SDK_VERSION = PACKAGE_VERSIONS.sdk; export const CODEX_EXECUTABLE_VERSION = PACKAGE_VERSIONS.executable; -export const BUNDLED_PLUGIN_VERSION = "0.1.94" as const; +export const BUNDLED_PLUGIN_VERSION = VERSION; const PACKAGE_NAME = "@openai/codex-security"; diff --git a/sdk/typescript/tests-ts/container-release-source.test.ts b/sdk/typescript/tests-ts/container-release-source.test.ts new file mode 100644 index 000000000..9f7a384a3 --- /dev/null +++ b/sdk/typescript/tests-ts/container-release-source.test.ts @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs"; +import { mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; +import { bashCommand, runCommand } from "./support/shell.js"; + +const verifier = readFileSync( + new URL( + "../../../docker/verify-container-release-source.sh", + import.meta.url, + ), + "utf8", +); +const roots: string[] = []; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function repository() { + const root = await realpath( + await mkdtemp(join(tmpdir(), "container-release-")), + ); + roots.push(root); + async function git(...args: string[]): Promise { + const result = await runCommand( + "git", + [ + "-c", + "user.name=Release Fixture", + "-c", + "user.email=release@example.test", + "-c", + "commit.gpgsign=false", + "-c", + "tag.gpgsign=false", + ...args, + ], + { cwd: root, timeout: 10_000 }, + ); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + } + await git("init", "--initial-branch=main"); + await writeFile(join(root, "package.json"), '{"version":"0.2.0"}\n'); + await git("add", "package.json"); + await git("commit", "-m", "Release fixture"); + const releaseCommit = await git("rev-parse", "HEAD"); + return { + git, + releaseCommit, + verify: (commit: string) => + runCommand(bashCommand(), ["-s", "--", "0.2.0", commit], { + cwd: root, + input: verifier, + timeout: 10_000, + }), + }; +} + +test.each([false, true])( + "accepts the npm release commit (annotated tag: %s)", + async (annotated) => { + const fixture = await repository(); + await fixture.git( + "tag", + ...(annotated ? ["-a", "-m", "Release fixture"] : []), + "npm-v0.2.0", + ); + const result = await fixture.verify(fixture.releaseCommit); + expect(result.status, result.stderr).toBe(0); + }, +); + +test("rejects a later commit even when its package version is unchanged", async () => { + const fixture = await repository(); + await fixture.git("tag", "npm-v0.2.0"); + await fixture.git("commit", "--allow-empty", "-m", "Later source commit"); + const result = await fixture.verify(await fixture.git("rev-parse", "HEAD")); + expect(result.status).toBe(1); + expect(result.stderr).toContain("same commit as npm-v0.2.0"); +}); + +test("requires the matching npm release tag", async () => { + const fixture = await repository(); + const result = await fixture.verify(fixture.releaseCommit); + expect(result.status).toBe(1); + expect(result.stderr).toContain("npm-v0.2.0 before publishing its container"); +}); diff --git a/sdk/typescript/tests-ts/skeleton.test.ts b/sdk/typescript/tests-ts/skeleton.test.ts index 5636217c8..7cb49810a 100644 --- a/sdk/typescript/tests-ts/skeleton.test.ts +++ b/sdk/typescript/tests-ts/skeleton.test.ts @@ -367,7 +367,7 @@ describe("TypeScript package skeleton", () => { "node scripts/check-plugin-source.mjs", ); expect(packageJson.scripts.prepack).toBe( - "node --run build:plugin && node --run build", + "node --run check:versions && node --run build:plugin && node --run build", ); expect(packageJson.scripts.types).not.toContain("check:plugin-source"); expect(packageJson.scripts["audit:prod"]).toBe( diff --git a/sdk/typescript/tests-ts/sync-versions.test.ts b/sdk/typescript/tests-ts/sync-versions.test.ts new file mode 100644 index 000000000..d7b497514 --- /dev/null +++ b/sdk/typescript/tests-ts/sync-versions.test.ts @@ -0,0 +1,109 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { afterEach, expect, test } from "bun:test"; + +const { syncVersions } = (await import( + new URL("../scripts/sync-versions.mjs", import.meta.url).href +)) as { + syncVersions: (options: { + root: string; + check?: boolean; + }) => Promise<{ version: string; changed: string[] }>; +}; + +const roots: string[] = []; +const manifests = [ + "plugins/codex-security/.codex-plugin/plugin.json", + "plugins/codex-security/mcp-app/package.json", + "plugins/codex-security/pyproject.toml", +]; + +afterEach(async () => { + for (const root of roots.splice(0)) { + await rm(root, { recursive: true, force: true }); + } +}); + +async function fixture(): Promise { + const root = await realpath( + await mkdtemp(join(tmpdir(), "release-versions-")), + ); + roots.push(root); + const files: Record = { + "sdk/typescript/package.json": JSON.stringify({ + name: "@openai/codex-security", + version: "0.2.0", + }), + "plugins/codex-security/.codex-plugin/plugin.json": + '{\n "name": "codex-security",\n "version": "0.1.79"\n}\n', + "plugins/codex-security/mcp-app/package.json": JSON.stringify( + { + name: "codex-security-mcp-app", + version: "0.1.158", + private: true, + dependencies: { "@openai/codex-sdk": "0.149.1" }, + }, + null, + 2, + ), + "plugins/codex-security/pyproject.toml": + '[project]\nname = "codex-security"\nversion = "0.1.0"\n\n[tool.ruff]\nrequired-version = "==0.16.1"\n', + }; + for (const [path, contents] of Object.entries(files)) { + await mkdir(dirname(join(root, path)), { recursive: true }); + await writeFile(join(root, path), contents); + } + return root; +} + +test("reports component version drift without rewriting manifests", async () => { + const root = await fixture(); + const before = await Promise.all( + manifests.map((path) => readFile(join(root, path), "utf8")), + ); + + await expect(syncVersions({ root, check: true })).rejects.toThrow( + manifests.join("\n"), + ); + expect( + await Promise.all( + manifests.map((path) => readFile(join(root, path), "utf8")), + ), + ).toEqual(before); +}); + +test("synchronizes component metadata while preserving dependency versions", async () => { + const root = await fixture(); + expect(await syncVersions({ root })).toEqual({ + version: "0.2.0", + changed: manifests, + }); + const plugin = JSON.parse( + await readFile(join(root, manifests[0]!), "utf8"), + ) as { version: string }; + const mcp = JSON.parse(await readFile(join(root, manifests[1]!), "utf8")) as { + version: string; + dependencies: Record; + private: boolean; + }; + expect(plugin.version).toBe("0.2.0"); + expect(mcp.version).toBe("0.2.0"); + expect(mcp.private).toBe(true); + expect(mcp.dependencies["@openai/codex-sdk"]).toBe("0.149.1"); + expect(await readFile(join(root, manifests[2]!), "utf8")).toBe( + '[project]\nname = "codex-security"\nversion = "0.2.0"\n\n[tool.ruff]\nrequired-version = "==0.16.1"\n', + ); + expect(await syncVersions({ root, check: true })).toEqual({ + version: "0.2.0", + changed: [], + }); + expect((await syncVersions({ root })).changed).toEqual([]); +});