diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 3b39ef3..c7a998c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -16,11 +16,11 @@ jobs: node-version: "24" - run: npm ci working-directory: mcp-server - - name: Sync manifest.json version from release tag + - name: Sync and verify all release metadata from tag working-directory: mcp-server env: TAG_NAME: ${{ github.event.release.tag_name }} - run: jq --arg v "${TAG_NAME#v}" '.version = $v' manifest.json > manifest.tmp && mv manifest.tmp manifest.json + run: node ../scripts/sync-release-version.mjs "$TAG_NAME" - run: npx mcpb pack working-directory: mcp-server - uses: actions/upload-artifact@v7 @@ -59,9 +59,9 @@ jobs: registry-url: "https://registry.npmjs.org" - run: npm ci working-directory: mcp-server - - name: Sync version from release tag + - name: Sync and verify all release metadata from tag working-directory: mcp-server - run: npm version "${TAG_NAME#v}" --no-git-tag-version --allow-same-version + run: node ../scripts/sync-release-version.mjs "$TAG_NAME" env: TAG_NAME: ${{ github.event.release.tag_name }} - name: Publish to npm diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ae573b..0b93fa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" + - run: node --test scripts/sync-release-version.test.mjs - run: npm ci - run: npm ci working-directory: mcp-server diff --git a/docs/0-requirements.ja.md b/docs/0-requirements.ja.md index c414126..f66d394 100644 --- a/docs/0-requirements.ja.md +++ b/docs/0-requirements.ja.md @@ -667,3 +667,9 @@ embedding が失敗した record は incomplete と分かる形で残し、次 新 API は `memory_history`、`record_source_use`、`record_outcome`。設定値、上限、ID/version、batch/idempotency、ledger、error、0008先行移行、既存行 backfill と artifact の契約は [2-feedback-memory.ja.md](2-feedback-memory.ja.md) に定義する。会話/本文/credential の複製は行わず、品質向上の主張は別評価を必要とする。 #259 の版契約: live inline doc/wiki は返した本文の SHA-256 を version とし、index snapshot と provenance を分ける。同じ索引 timestamp でも live 本文更新は別 source_id にする。本文を memory に複製しない。 + +## リリース metadata の一致(Issue #261) + +両 CD packaging job は bridge package、lock root、MCP Bundle manifest、MCP registry の npm version を同じ検証済み release tag から同期し、pack / publish 前に一致を assert する。依存版、schema、runtime 条件、Worker の挙動を保持する。対象 field と検証 command は [版数と公開成果物](installation.ja.md#versioning-and-published-artifacts) に定義する。commit 済み版は placeholder のままとし、trusted publishing は別変更とする。 + +source の `docs/_Sidebar.md` は既存 Feedback Memory EN/JA ページを参照し、release 後の Wiki navigation に含める。 diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 0de8cd2..403aee2 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -672,3 +672,9 @@ Implement retrieval history, selected/validated/used records, retrieved/usage ac The APIs are `memory_history`, `record_source_use`, and `record_outcome`. [2-feedback-memory.md](2-feedback-memory.md) owns constants, bounds, source version identity, batch/idempotency, ledger, errors, migration 0008-before-deployment, historical backfill and bridge artifacts. Do not duplicate conversations, bodies or credentials. Claims of retrieval quality improvement require separate evaluation. Issue #259 version contract: live inline doc/wiki uses the returned-text SHA-256 version and provenance separate from the index snapshot. Changed live text gets a different source ID even at the same index timestamp. Do not duplicate the body in memory. + +## Release metadata consistency (Issue #261) + +Both CD packaging jobs must derive the bridge package, lock root, MCP Bundle manifest and MCP registry npm versions from the same validated release tag and assert agreement before packing or publishing. Preserve dependency versions, schemas, runtime constraints and Worker behavior. [Versioning and published artifacts](installation.md#versioning-and-published-artifacts) owns the affected fields and validation command. Committed versions remain placeholders; trusted publishing is a separate change. + +The source `docs/_Sidebar.md` must link the existing Feedback Memory EN/JA pages so release wiki navigation includes them. diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index ac67f33..e4fce4b 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -4,3 +4,4 @@ - Installation: [[EN|installation]] / [[JA|installation.ja]] - Requirements: [[EN|0-requirements]] / [[JA|0-requirements.ja]] - Memory Philosophy: [[EN|1-memory-philosophy]] / [[JA|1-memory-philosophy.ja]] +- Feedback Memory: [[EN|2-feedback-memory]] / [[JA|2-feedback-memory.ja]] diff --git a/docs/installation.ja.md b/docs/installation.ja.md index 36518b7..0c4d790 100644 --- a/docs/installation.ja.md +++ b/docs/installation.ja.md @@ -462,25 +462,28 @@ POST /admin/backfill-issue-index?repo=owner/repo ## Versioning and published artifacts -公開される成果物の版数は **GitHub Release の tag** から来る。リポジトリにコミットされている `version` フィールドはどれもソースではない。 +公開 bridge の版は **GitHub Release tag** から生成する。tag は `v` に canonical SemVer を続けた形(例 `v0.12.0`、`v1.2.3-rc.1`)。commit 済みの版は placeholder とする。 -`.github/workflows/cd.yml` は `release: published` を契機に走り、pack / publish の前に tag から版数を振り直す。 +`.github/workflows/cd.yml` の独立した両 packaging job は、pack / publish 前に `mcp-server/` から同じ command を呼ぶ。 -- npm — `mcp-server/` で `npm version "${TAG_NAME#v}" --no-git-tag-version --allow-same-version` -- `.mcpb` bundle — `mcp-server/` で `jq --arg v "${TAG_NAME#v}" '.version = $v' manifest.json` +```sh +node ../scripts/sync-release-version.mjs "$TAG_NAME" +``` -したがって作業ツリー上の `version` 値は公開成果物に一切届かない。 +script は tag 全体と metadata 4 ファイルを検証してから書き込み、次の field だけを同期する。書き込み後に読み直し、全項目が tag の `v` を除いた版に一致することを assert する。 -| 場所 | 役割 | +| 場所 | 更新する field | |---|---| -| `package.json`(root) | worker の build 用のみ。`private: true` で公開されない | -| `mcp-server/package.json` | placeholder。公開時に tag から上書きされる | -| `mcp-server/manifest.json` | placeholder。公開時に tag から上書きされる | -| `mcp-server/server.json` | npm tarball に同梱される MCP registry metadata。release workflow は読みも書き換えもしない | +| `mcp-server/package.json` | `version`。実行時 MCP serverInfo / remote clientInfo もこの版を使う | +| `mcp-server/package-lock.json` | `version` と `packages[""].version` | +| `mcp-server/manifest.json` | `version` | +| `mcp-server/server.json` | `version` と全 `registryType: "npm"` package entry の `version` | + +依存版、schema 識別子、Node 条件、非 npm registry entry は保持する。private な root package の Worker 版は対象外。不正 tag、読取不可・不正 JSON、version field 欠落、書込後の不一致は pack / publish step より前に job を止める。既存の npm 認証と release asset upload は維持する。 -これらの値が公開版数より遅れているのは設計どおりの状態であって、不整合ではない。手で合わせる対象でもない — 次のリリースがどのみち自身の tag から上書きするので、手編集は「このファイルが権威である」という誤った印象を残すだけになる。 +`--check` を付けると生成済み metadata の一致だけを検証し、書き込まない。`node --test scripts/sync-release-version.test.mjs` と CI で、複数版、書込前の拒否、対象外 field 保持、retry、CD と同じ相対 command を検証する。 -実際に公開されている版数を見るには、release tag(`gh release list`)か registry(`npm view github-rag-mcp version`)を参照する。 +作業ツリーの placeholder が公開版より遅れていてもよいが、**各公開 artifact 内の metadata は tag と一致する必要がある**。一つのファイルだけの同期では足りない。公開版の確認には release tag または npm registry を使う。この同期処理自体は publish / tag 作成 / Worker deploy を行わない。 ## Troubleshooting diff --git a/docs/installation.md b/docs/installation.md index 52495a8..9a30d23 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -462,25 +462,28 @@ Operational notes: ## Versioning and published artifacts -The version of every published artifact comes from the **GitHub Release tag**. No `version` field committed in this repository is the source. +Published bridge versions come from a **GitHub Release tag**, using `v` followed by canonical SemVer (for example `v0.12.0` or `v1.2.3-rc.1`). Committed versions are placeholders. -`.github/workflows/cd.yml` runs on `release: published` and rewrites the version from the tag before it packs or publishes: +Both isolated packaging jobs in `.github/workflows/cd.yml` run the same command from `mcp-server/` before packing or publishing: -- npm — `npm version "${TAG_NAME#v}" --no-git-tag-version --allow-same-version` in `mcp-server/` -- `.mcpb` bundle — `jq --arg v "${TAG_NAME#v}" '.version = $v' manifest.json` in `mcp-server/` +```sh +node ../scripts/sync-release-version.mjs "$TAG_NAME" +``` -So the `version` values sitting in the working tree never reach a published artifact: +The script validates the entire tag and all four metadata files before writing. It synchronizes only these fields, then re-reads the files and asserts that every field equals the tag without its `v` prefix: -| Location | Role | +| Location | Fields rewritten | |---|---| -| `package.json` (root) | worker build only, `private: true`, never published | -| `mcp-server/package.json` | placeholder, overwritten from the tag at publish time | -| `mcp-server/manifest.json` | placeholder, overwritten from the tag at publish time | -| `mcp-server/server.json` | MCP registry metadata carried in the npm tarball; the release workflow neither reads nor rewrites it | +| `mcp-server/package.json` | `version`, also used by runtime MCP serverInfo and remote clientInfo | +| `mcp-server/package-lock.json` | `version` and `packages[""].version` | +| `mcp-server/manifest.json` | `version` | +| `mcp-server/server.json` | `version` and every `registryType: "npm"` package entry's `version` | + +Dependency versions, schema identifiers, Node requirements and non-npm registry entries are preserved. The private root package's Worker version is outside this contract. Invalid tags, unreadable/malformed metadata, missing version fields or mismatched on-disk output fail the job before its pack/publish step. Existing npm authentication and release asset upload remain unchanged. -These values are expected to lag behind the published version. That divergence is the designed state, not a defect, and it is not something to repair by hand: the next release overwrites them from its own tag regardless, so a manual edit only leaves the impression that the file is authoritative. +Add `--check` to verify already-generated metadata without writing. Local fixture regression coverage runs with `node --test scripts/sync-release-version.test.mjs` and in CI; it checks multiple versions, rejection before writes, field preservation, replay and the actual CD relative command. -To read the version that is actually published, look at the release tag (`gh release list`) or the registry (`npm view github-rag-mcp version`). +Working-tree placeholders may lag behind a release. Metadata **inside each published artifact** must agree with the tag; updating one file alone is insufficient. To inspect the published version, use the release tag or npm registry. This synchronization does not publish, create tags or deploy the Worker. ## Troubleshooting diff --git a/scripts/sync-release-version.mjs b/scripts/sync-release-version.mjs new file mode 100644 index 0000000..519caf6 --- /dev/null +++ b/scripts/sync-release-version.mjs @@ -0,0 +1,119 @@ +#!/usr/bin/env node +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isDeepStrictEqual } from 'node:util'; + +const FILES = ['package.json', 'package-lock.json', 'manifest.json', 'server.json']; +const CORE = '(0|[1-9][0-9]*)'; +const TAG_PATTERN = new RegExp(`^v${CORE}\\.${CORE}\\.${CORE}(?:-([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$`); +const DEFAULT_PACKAGE_DIR = fileURLToPath(new URL('../mcp-server/', import.meta.url)); + +export function parseReleaseTag(tag) { + const match = typeof tag === 'string' ? TAG_PATTERN.exec(tag) : null; + if (!match || match[0] !== tag || (match[4] && match[4].split('.').some(part => /^0[0-9]+$/.test(part)))) { + throw new Error('Release tag must be v-prefixed canonical SemVer'); + } + return tag.slice(1); +} + +function object(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function versionPaths(name, data) { + if (!object(data) || typeof data.version !== 'string') throw new Error(`Invalid release metadata: ${name}`); + const paths = [['version']]; + if (name === 'package-lock.json') { + if (!object(data.packages) || !object(data.packages['']) || typeof data.packages[''].version !== 'string') { + throw new Error('Invalid package-lock root version'); + } + paths.push(['packages', '', 'version']); + } + if (name === 'server.json') { + if (!Array.isArray(data.packages)) throw new Error('Invalid registry packages'); + const npm = data.packages.flatMap((entry, index) => object(entry) && entry.registryType === 'npm' ? [index] : []); + if (!npm.length || npm.some(index => typeof data.packages[index].version !== 'string')) { + throw new Error('Registry metadata must contain versioned npm entries'); + } + for (const index of npm) paths.push(['packages', index, 'version']); + } + return paths; +} + +function get(data, path) { return path.reduce((value, key) => value[key], data); } +function set(data, path, value) { + const parent = path.slice(0, -1).reduce((current, key) => current[key], data); + parent[path.at(-1)] = value; +} + +async function load(packageDir) { + const records = []; + for (const name of FILES) { + let text, data; + try { + text = await readFile(resolve(packageDir, name), 'utf8'); + data = JSON.parse(text); + } catch { throw new Error(`Cannot read release metadata: ${name}`); } + records.push({ name, text, data, paths: versionPaths(name, data) }); + } + return records; +} + +function assertVersion(records, version) { + for (const record of records) { + for (const path of record.paths) { + if (get(record.data, path) !== version) throw new Error(`Release version mismatch: ${record.name}:${JSON.stringify(path)}`); + } + } +} + +export async function syncReleaseVersion({ tag, packageDir = DEFAULT_PACKAGE_DIR, checkOnly = false }) { + const version = parseReleaseTag(tag); + const records = await load(packageDir); + if (checkOnly) { + assertVersion(records, version); + return { version, checkedFields: records.reduce((count, record) => count + record.paths.length, 0), changedFiles: 0 }; + } + + // Validate every input and prepare every change before writing any file. + const planned = records.map(record => { + const next = structuredClone(record.data); + for (const path of record.paths) set(next, path, version); + const restored = structuredClone(next); + for (const path of record.paths) set(restored, path, get(record.data, path)); + if (!isDeepStrictEqual(restored, record.data)) throw new Error('Unexpected change outside release version fields'); + return { ...record, next, output: JSON.stringify(next, null, 2) + '\n' }; + }); + let changedFiles = 0; + for (const record of planned) { + if (record.output === record.text) continue; + await writeFile(resolve(packageDir, record.name), record.output, 'utf8'); + changedFiles++; + } + + // Re-read on-disk metadata; a packaging job must stop on any disagreement. + const written = await load(packageDir); + assertVersion(written, version); + for (let index = 0; index < planned.length; index++) { + if (!isDeepStrictEqual(written[index].data, planned[index].next)) throw new Error('Release metadata write verification failed'); + } + return { version, checkedFields: written.reduce((count, record) => count + record.paths.length, 0), changedFiles }; +} + +async function main(args) { + let tag, packageDir = DEFAULT_PACKAGE_DIR, checkOnly = false; + for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === '--check' && !checkOnly) checkOnly = true; + else if (arg === '--package-dir' && index + 1 < args.length) packageDir = resolve(args[++index]); + else if (!tag && !arg.startsWith('--')) tag = arg; + else throw new Error('Usage: sync-release-version.mjs [--check] [--package-dir directory]'); + } + const result = await syncReleaseVersion({ tag, packageDir, checkOnly }); + console.log(`Release metadata ${checkOnly ? 'verified' : 'synchronized'}: ${result.version}; ${result.checkedFields} fields; ${result.changedFiles} changed files`); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main(process.argv.slice(2)).catch(error => { console.error(error.message); process.exitCode = 1; }); +} diff --git a/scripts/sync-release-version.test.mjs b/scripts/sync-release-version.test.mjs new file mode 100644 index 0000000..bff769a --- /dev/null +++ b/scripts/sync-release-version.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { parseReleaseTag, syncReleaseVersion } from './sync-release-version.mjs'; + +const scripts = dirname(fileURLToPath(import.meta.url)); +const names = ['package.json', 'package-lock.json', 'manifest.json', 'server.json']; +const baseline = Object.fromEntries(await Promise.all(names.map(async name => [name, await readFile(join(scripts, '../mcp-server', name))]))); +const snapshot = async directory => Object.fromEntries(await Promise.all(names.map(async name => [name, await readFile(join(directory, name))]))); +const unchanged = async (directory, saved) => { + const current = await snapshot(directory); + for (const name of names) assert.deepEqual(current[name], saved[name]); +}; +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'github-rag-release-')); + t.after(async () => { + assert.ok(resolve(root).startsWith(resolve(tmpdir()) + sep)); + assert.ok(basename(root).startsWith('github-rag-release-')); + await rm(root, { recursive: true, force: true }); + }); + const directory = join(root, 'mcp-server'); + await mkdir(directory); + for (const name of names) await writeFile(join(directory, name), baseline[name]); + return { root, directory }; +} + +for (const tag of ['v0.12.0', 'v1.2.3-rc.1', 'v2.0.0+build.007', 'v3.4.5-beta.0+ci.9']) { + test(`synchronizes ${tag}, preserves unrelated metadata and replays without writes`, async t => { + const { directory } = await fixture(t); + const registryPath = join(directory, 'server.json'); + const registry = JSON.parse(await readFile(registryPath, 'utf8')); + registry.packages.push({ registryType: 'npm', identifier: 'synthetic-extra', version: '0.9.0' }); + registry.packages.push({ registryType: 'oci', identifier: 'synthetic-image', version: '5.6.7' }); + await writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n'); + const original = await snapshot(directory); + await assert.rejects(syncReleaseVersion({ tag, packageDir: directory, checkOnly: true }), /version mismatch/); + await unchanged(directory, original); + const result = await syncReleaseVersion({ tag, packageDir: directory }); + assert.equal(result.checkedFields, 7); + const generated = await snapshot(directory); + for (const name of names) { + const expected = JSON.parse(original[name].toString('utf8')); + expected.version = tag.slice(1); + if (name === 'package-lock.json') expected.packages[''].version = tag.slice(1); + if (name === 'server.json') for (const entry of expected.packages) if (entry.registryType === 'npm') entry.version = tag.slice(1); + assert.deepEqual(JSON.parse(generated[name].toString('utf8')), expected); + } + assert.equal((await syncReleaseVersion({ tag, packageDir: directory })).changedFiles, 0); + await syncReleaseVersion({ tag, packageDir: directory, checkOnly: true }); + await unchanged(directory, generated); + const wrong = JSON.parse(generated['server.json'].toString('utf8')); + wrong.packages[1].version = '0.0.1'; + await writeFile(registryPath, JSON.stringify(wrong, null, 2) + '\n'); + const mismatched = await snapshot(directory); + await assert.rejects(syncReleaseVersion({ tag, packageDir: directory, checkOnly: true }), /version mismatch/); + await unchanged(directory, mismatched); + }); +} + +test('rejects invalid release tags before writing metadata', async t => { + const { directory } = await fixture(t); + const original = await snapshot(directory); + const invalid = ['0.12.0', 'v01.2.3', 'v1.02.3', 'v1.2.03', 'v1.2', 'v1.2.3-01', 'v1.2.3-alpha..1', 'v1.2.3+', 'v1.2.3\n', 'v1.2.3 snow', 'v1.2.3-rc.1']; + for (const tag of invalid) { + assert.throws(() => parseReleaseTag(tag), /canonical SemVer/); + await assert.rejects(syncReleaseVersion({ tag, packageDir: directory }), /canonical SemVer/); + await unchanged(directory, original); + } +}); + +for (const fault of ['no-npm-entry', 'missing-lock-root', 'missing-version', 'invalid-json', 'missing-file']) { + test(`rejects ${fault} before modifying any metadata`, async t => { + const { directory } = await fixture(t); + if (fault === 'no-npm-entry') { + const registry = JSON.parse(baseline['server.json'].toString('utf8')); + registry.packages = []; + await writeFile(join(directory, 'server.json'), JSON.stringify(registry)); + } else if (fault === 'missing-lock-root') { + const lock = JSON.parse(baseline['package-lock.json'].toString('utf8')); + delete lock.packages['']; + await writeFile(join(directory, 'package-lock.json'), JSON.stringify(lock)); + } else if (fault === 'missing-version') { + const manifest = JSON.parse(baseline['manifest.json'].toString('utf8')); + delete manifest.version; + await writeFile(join(directory, 'manifest.json'), JSON.stringify(manifest)); + } else if (fault === 'invalid-json') await writeFile(join(directory, 'manifest.json'), '{'); + else await rm(join(directory, 'server.json')); + const saved = Object.fromEntries(await Promise.all(names.filter(name => fault !== 'missing-file' || name !== 'server.json').map(async name => [name, await readFile(join(directory, name))]))); + await assert.rejects(syncReleaseVersion({ tag: 'v0.12.0', packageDir: directory })); + for (const [name, bytes] of Object.entries(saved)) assert.deepEqual(await readFile(join(directory, name)), bytes); + }); +} + +test('CD relative command resolves default directory and both jobs run it before output', async t => { + const { root, directory } = await fixture(t); + await mkdir(join(root, 'scripts')); + await writeFile(join(root, 'scripts/sync-release-version.mjs'), await readFile(join(scripts, 'sync-release-version.mjs'))); + const child = spawnSync(process.execPath, ['../scripts/sync-release-version.mjs', 'v0.12.0'], { + cwd: directory, encoding: 'utf8', windowsHide: true, + env: { SystemRoot: process.env.SystemRoot, WINDIR: process.env.WINDIR, PATH: dirname(process.execPath) }, + }); + assert.equal(child.status, 0, child.stderr); + await syncReleaseVersion({ tag: 'v0.12.0', packageDir: directory, checkOnly: true }); + const workflow = await readFile(join(scripts, '../.github/workflows/cd.yml'), 'utf8'); + const bundle = workflow.slice(workflow.indexOf(' build-mcpb:'), workflow.indexOf(' attach-mcpb:')); + const npm = workflow.slice(workflow.indexOf(' npm-publish:')); + const command = 'run: node ../scripts/sync-release-version.mjs "$TAG_NAME"'; + for (const [job, output] of [[bundle, 'npx mcpb pack'], [npm, 'npm publish --access public']]) { + assert.ok(job.includes(command)); + assert.ok(job.includes('working-directory: mcp-server')); + assert.ok(job.includes('TAG_NAME: ${{ github.event.release.tag_name }}')); + assert.ok(job.indexOf(command) < job.indexOf(output)); + } +});