diff --git a/.github/workflows/mcp-ui-version-sync.yml b/.github/workflows/mcp-ui-version-sync.yml new file mode 100644 index 0000000000..8ab1fbc5ff --- /dev/null +++ b/.github/workflows/mcp-ui-version-sync.yml @@ -0,0 +1,91 @@ +# Keeps apps/loopover-ui/src/lib/mcp-package.ts's MCP_PACKAGE_KNOWN_LATEST_VERSION -- the client-side +# graceful-degradation fallback shown when the live npm-registry fetch (useMcpPackageMetadata) fails or hasn't +# resolved yet -- in sync with @loopover/mcp's real npm dist-tags.latest, without ever requiring a human/agent +# to notice a new release and manually edit the constant (#6580). scripts/check-ui-mcp-version-copy.mjs's +# --write mode does the actual sync; this just runs it on a schedule and keeps a standing PR up to date, the +# same shape as orb-stable-release-pr.yml. Nothing ships until a maintainer reviews and merges the PR -- this +# workflow never pushes to main directly. +name: mcp-ui-version-sync + +on: + workflow_dispatch: + schedule: + - cron: "30 16 * * *" + +permissions: + contents: write # push the mcp-ui-version-sync branch + pull-requests: write # create/update the sync PR + +concurrency: + group: mcp-ui-version-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Setup Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 + with: + node-version: 24.18.0 + + # No npm ci -- the script only imports Node built-ins. + - name: Sync the known-latest constant + run: node scripts/check-ui-mcp-version-copy.mjs --write + + - name: Check for a diff + id: diff + run: | + set -euo pipefail + if git diff --quiet; then + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + + - name: Extract the synced version + if: steps.diff.outputs.changed == 'true' + id: version + run: | + set -euo pipefail + version="$(grep -oP 'MCP_PACKAGE_KNOWN_LATEST_VERSION\s*=\s*"\K[^"]+' apps/loopover-ui/src/lib/mcp-package.ts)" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Push the mcp-ui-version-sync branch + if: steps.diff.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -B mcp-ui-version-sync + git add apps/loopover-ui/src/lib/mcp-package.ts + git commit -m "chore(ui): sync MCP known-latest to ${VERSION}" + git remote set-url origin "https://github.com/${GITHUB_REPOSITORY}.git" + gh auth setup-git + git push --force origin mcp-ui-version-sync + + - name: Create or refresh the sync PR + if: steps.diff.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + title="chore(ui): sync MCP known-latest to ${VERSION}" + body="Auto-generated by mcp-ui-version-sync.yml: \`@loopover/mcp\`'s npm dist-tags.latest is \`${VERSION}\`, ahead of the client-side fallback constant. This constant only shows when the live npm registry fetch fails or hasn't resolved yet (see apps/loopover-ui/src/lib/mcp-package.ts's useMcpPackageMetadata), so keeping it current avoids showing a stale version in that degraded state." + existing="$(gh pr list --head mcp-ui-version-sync --state open --json number --jq '.[0].number // empty')" + if [ -n "$existing" ]; then + gh pr edit "$existing" --title "$title" --body "$body" + else + gh pr create --head mcp-ui-version-sync --base main --title "$title" --body "$body" + fi diff --git a/package.json b/package.json index 645e0cf636..18dc8dba4f 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "ui:openapi:settings-parity": "tsx scripts/check-openapi-settings-parity.mjs", "cloudflare:schema": "tsx scripts/write-cloudflare-schema.ts", "ui:version-audit": "node scripts/check-ui-mcp-version-copy.mjs", + "ui:version-audit:sync": "node scripts/check-ui-mcp-version-copy.mjs --write", "docs:drift-check": "node scripts/check-docs-drift.mjs", "manifest:drift-check": "tsx scripts/check-manifest-drift.mjs", "engine-parity:drift-check": "tsx scripts/check-engine-parity.ts", diff --git a/scripts/check-ui-mcp-version-copy.d.mts b/scripts/check-ui-mcp-version-copy.d.mts index f628519d9a..f11420d1e6 100644 --- a/scripts/check-ui-mcp-version-copy.d.mts +++ b/scripts/check-ui-mcp-version-copy.d.mts @@ -27,6 +27,11 @@ export declare function buildStaleVersionMatchers( export declare function readKnownLatestVersion(path: string): string; +export declare function writeKnownLatestVersion( + path: string, + newVersion: string, +): void; + export declare function readMinimumSupportedVersion(path: string): string; export declare function fetchLatestVersion(): Promise; diff --git a/scripts/check-ui-mcp-version-copy.mjs b/scripts/check-ui-mcp-version-copy.mjs index d02ce0b77e..8a8dbe6ea0 100644 --- a/scripts/check-ui-mcp-version-copy.mjs +++ b/scripts/check-ui-mcp-version-copy.mjs @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { readdirSync, readFileSync, statSync } from "node:fs"; +import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { get } from "node:https"; import { pathToFileURL } from "node:url"; @@ -42,10 +42,21 @@ async function main() { ); const failures = []; + // --write: self-heal the known-latest fallback instead of failing, so keeping it in sync with npm is a + // scheduled bot commit (see .github/workflows/mcp-ui-version-sync.yml), never a manual edit (#6580). CI's + // own invocation never passes --write, so a contributor PR still gets a hard failure on real drift. + const write = process.argv.includes("--write"); if (latest && sourceLatest !== latest) { - failures.push( - `${SOURCE_LATEST_PATH}: known latest ${sourceLatest} does not match npm dist-tags.latest ${latest}`, - ); + if (write) { + writeKnownLatestVersion(sourceLatestPath, latest); + console.log( + `${SOURCE_LATEST_PATH}: updated known latest ${sourceLatest} -> ${latest}`, + ); + } else { + failures.push( + `${SOURCE_LATEST_PATH}: known latest ${sourceLatest} does not match npm dist-tags.latest ${latest}`, + ); + } } else if (!latest) { console.warn( `::warning::skipped the npm dist-tag drift check (registry unavailable: ${latestSkipReason}); set LOOPOVER_MCP_LATEST_VERSION to enforce it offline`, @@ -175,6 +186,18 @@ export function readKnownLatestVersion(path) { return match[1]; } +export function writeKnownLatestVersion(path, newVersion) { + const text = readFileSync(path, "utf8"); + const pattern = /MCP_PACKAGE_KNOWN_LATEST_VERSION\s*=\s*"([^"]+)"/; + if (!pattern.test(text)) + throw new Error("Could not find MCP_PACKAGE_KNOWN_LATEST_VERSION."); + const updated = text.replace( + pattern, + `MCP_PACKAGE_KNOWN_LATEST_VERSION = "${newVersion}"`, + ); + writeFileSync(path, updated); +} + export function readMinimumSupportedVersion(path) { const text = readFileSync(path, "utf8"); const match = /MCP_MINIMUM_SUPPORTED_VERSION\s*=\s*"([^"]+)"/.exec(text); diff --git a/test/unit/check-ui-mcp-version-copy-script.test.ts b/test/unit/check-ui-mcp-version-copy-script.test.ts index 5540e7d4eb..06c479d1ed 100644 --- a/test/unit/check-ui-mcp-version-copy-script.test.ts +++ b/test/unit/check-ui-mcp-version-copy-script.test.ts @@ -1,13 +1,15 @@ import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { buildStaleVersionMatchers, collectVersionCopyFailures, isMinimumSupportedContext, readMinimumSupportedVersion, SOURCE_LATEST_PATH, + writeKnownLatestVersion, } from "../../scripts/check-ui-mcp-version-copy.mjs"; const root = process.cwd(); @@ -119,4 +121,86 @@ describe("check-ui-mcp-version-copy script (#6292)", () => { `minimum floor ${declaredConstant("MCP_MINIMUM_SUPPORTED_VERSION")}`, ); }); + + describe("writeKnownLatestVersion (#6580: self-healing known-latest, never a manual bump)", () => { + let tempDir: string | undefined; + + afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + }); + + it("replaces only the known-latest constant, leaving the rest of the file untouched", () => { + tempDir = mkdtempSync(join(tmpdir(), "mcp-known-latest-")); + const filePath = join(tempDir, "mcp-package.ts"); + writeFileSync( + filePath, + 'export const MCP_PACKAGE_NAME = "@loopover/mcp";\nexport const MCP_PACKAGE_KNOWN_LATEST_VERSION = "0.6.0";\nexport const MCP_MINIMUM_SUPPORTED_VERSION = "0.5.0";\n', + ); + + writeKnownLatestVersion(filePath, "0.9.0"); + + const updated = readFileSync(filePath, "utf8"); + expect(updated).toContain('MCP_PACKAGE_KNOWN_LATEST_VERSION = "0.9.0"'); + expect(updated).toContain('MCP_PACKAGE_NAME = "@loopover/mcp"'); // unrelated lines preserved + expect(updated).toContain('MCP_MINIMUM_SUPPORTED_VERSION = "0.5.0"'); // the floor is untouched + }); + + it("throws when the target file has no known-latest constant to update", () => { + tempDir = mkdtempSync(join(tmpdir(), "mcp-known-latest-")); + const filePath = join(tempDir, "mcp-package.ts"); + writeFileSync(filePath, "export const SOMETHING_ELSE = 1;\n"); + + expect(() => writeKnownLatestVersion(filePath, "0.9.0")).toThrow( + /Could not find MCP_PACKAGE_KNOWN_LATEST_VERSION/, + ); + }); + }); + + describe("--write CLI mode (#6580)", () => { + let tempDir: string | undefined; + + afterEach(() => { + if (tempDir) rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + }); + + // Runs the real CLI end-to-end, but against a disposable temp repo layout -- never the real + // apps/loopover-ui/src/lib/mcp-package.ts, so the test can never mutate this repo's own source file. + function seedTempRepo(knownLatest: string): string { + const dir = mkdtempSync(join(tmpdir(), "mcp-write-cli-")); + mkdirSync(join(dir, "apps/loopover-ui/src/lib"), { recursive: true }); + mkdirSync(join(dir, "packages/loopover-mcp"), { recursive: true }); + writeFileSync(join(dir, "README.md"), "# repo\n"); + writeFileSync(join(dir, "packages/loopover-mcp/README.md"), "# mcp\n"); + writeFileSync( + join(dir, "apps/loopover-ui/src/lib/mcp-package.ts"), + `export const MCP_PACKAGE_KNOWN_LATEST_VERSION = "${knownLatest}";\nexport const MCP_MINIMUM_SUPPORTED_VERSION = "0.5.0";\n`, + ); + return dir; + } + + it("self-heals a stale known-latest constant instead of failing", () => { + tempDir = seedTempRepo("0.6.0"); + const out = execFileSync( + process.execPath, + [join(process.cwd(), SCRIPT_PATH), "--write"], + { encoding: "utf8", cwd: tempDir, env: { ...process.env, LOOPOVER_MCP_LATEST_VERSION: "0.9.0" } }, + ); + expect(out).toContain("updated known latest 0.6.0 -> 0.9.0"); + const updated = readFileSync(join(tempDir, "apps/loopover-ui/src/lib/mcp-package.ts"), "utf8"); + expect(updated).toContain('MCP_PACKAGE_KNOWN_LATEST_VERSION = "0.9.0"'); + }); + + it("is a no-op when the known-latest constant is already current", () => { + tempDir = seedTempRepo("0.9.0"); + const out = execFileSync( + process.execPath, + [join(process.cwd(), SCRIPT_PATH), "--write"], + { encoding: "utf8", cwd: tempDir, env: { ...process.env, LOOPOVER_MCP_LATEST_VERSION: "0.9.0" } }, + ); + expect(out).not.toContain("updated known latest"); + expect(out).toContain("MCP UI version copy ok"); + }); + }); });