Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/mcp-ui-version-sync.yml
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions scripts/check-ui-mcp-version-copy.d.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
31 changes: 27 additions & 4 deletions scripts/check-ui-mcp-version-copy.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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);
Expand Down
88 changes: 86 additions & 2 deletions test/unit/check-ui-mcp-version-copy-script.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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");
});
});
});
Loading