From b34fc2bbcd88e591b6a1fe7adf71d809320d4da8 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:15:05 +0800 Subject: [PATCH 1/3] Retry transient website link-check failures --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ce1f15d..fd18edb 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check:html": "html-validate index.html", - "check:links": "linkinator dist --recurse --skip '^https://computer-mcp.github.io'", + "check:links": "linkinator dist --recurse --skip '^https://computer-mcp.github.io' --concurrency 5 --retry-errors --retry-errors-count 3", "test": "npm run catalog:test && node --test scripts/release.test.mjs && playwright test", "catalog:test": "python3 -m unittest discover -s scripts -p 'test_plugin_catalog*.py'", "catalog:check": "python3 scripts/plugin_catalog.py check", From e3e55db547764e5ad8e386810c117f9a024c378e Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:43:06 +0800 Subject: [PATCH 2/3] Verify official GitHub targets during page service errors --- .github/workflows/ci.yml | 2 + .github/workflows/pages.yml | 2 + README.md | 7 ++ package.json | 4 +- scripts/check-links.mjs | 137 +++++++++++++++++++++++++++++++ scripts/check-links.test.mjs | 155 +++++++++++++++++++++++++++++++++++ 6 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 scripts/check-links.mjs create mode 100644 scripts/check-links.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c52448d..d2a1843 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,8 @@ jobs: run: npm run build - name: Validate links + env: + GH_TOKEN: ${{ github.token }} run: npm run check:links - name: Install browser diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index fb4fdc1..226f8dc 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -64,6 +64,8 @@ jobs: run: npm ci - name: Verify website + env: + GH_TOKEN: ${{ github.token }} run: | npm run release:check npm run catalog:check-policy diff --git a/README.md b/README.md index af9bbac..f66a4ea 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ npm test The production build is written to `dist/`. GitHub Pages deploys only that artifact from the official repository's `main` branch after validation and catalog reconciliation. +Link validation retries transient HTTP errors with bounded concurrency. When an official GitHub +repository, file, directory, release or security-policy link returns a server error, it verifies the +corresponding public target through the GitHub API. Client errors, missing targets and API failures +still fail validation. `GH_TOKEN` or `GITHUB_TOKEN` can provide API authentication; the token is +sent only to the GitHub API. CI uses its read-only repository token. Content verification covers +`main`, `master` and full commit hashes; other content refs retain HTTP validation. + `public/release.json` binds the deployed site to the product's delivered commit and release tag. The private website package version describes this build project, not the product version. After the main repository publishes an accepted release, run `npm run release:update -- vX.Y.Z` to import its diff --git a/package.json b/package.json index fd18edb..537be4f 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "check:html": "html-validate index.html", - "check:links": "linkinator dist --recurse --skip '^https://computer-mcp.github.io' --concurrency 5 --retry-errors --retry-errors-count 3", - "test": "npm run catalog:test && node --test scripts/release.test.mjs && playwright test", + "check:links": "node scripts/check-links.mjs", + "test": "npm run catalog:test && node --test scripts/release.test.mjs scripts/check-links.test.mjs && playwright test", "catalog:test": "python3 -m unittest discover -s scripts -p 'test_plugin_catalog*.py'", "catalog:check": "python3 scripts/plugin_catalog.py check", "catalog:check-policy": "python3 scripts/plugin_catalog.py check-policy", diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs new file mode 100644 index 0000000..5bab895 --- /dev/null +++ b/scripts/check-links.mjs @@ -0,0 +1,137 @@ +import { LinkChecker, LinkState } from "linkinator"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +export function githubTarget(link) { + const url = new URL(link); + if (url.origin !== "https://github.com" || url.search || url.username || url.password) + return null; + const parts = url.pathname.replace(/\/$/, "").split("/").slice(1); + if (parts[0] !== "computer-mcp" || !/^[\w.-]+$/.test(parts[1] ?? "")) return null; + const repository = `${parts[0]}/${parts[1]}`; + if (parts.length === 2) return { repository, kind: "repository" }; + if (parts.length === 3 && parts[2] === "releases") return { repository, kind: "releases" }; + if (parts.length === 4 && parts[2] === "releases" && parts[3] === "latest") + return { repository, kind: "latest" }; + if (parts.length === 4 && parts[2] === "security" && parts[3] === "policy") + return { repository, kind: "policy" }; + if ( + parts.length < 5 || + !["blob", "tree"].includes(parts[2]) || + !/^(main|master|[0-9a-f]{40})$/.test(parts[3]) + ) + return null; + const path = parts.slice(4).map((part) => decodeURIComponent(part)); + if (path.some((part) => !part || part === "." || part === ".." || /[\\/]/.test(part))) + return null; + return { + repository, + kind: parts[2] === "blob" ? "file" : "directory", + ref: parts[3], + path: path.map(encodeURIComponent).join("/"), + }; +} + +export async function githubJSON(endpoint) { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + const response = await fetch(`https://api.github.com/${endpoint}`, { + headers: { + Accept: "application/vnd.github+json", + "User-Agent": "computer-mcp-website-link-check", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + redirect: "error", + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`GitHub API returned HTTP ${response.status}`); + return response.json(); +} + +export async function resolveFailures(links, request = githubJSON) { + const requests = new Map(); + const get = (endpoint) => { + if (!requests.has(endpoint)) + requests.set( + endpoint, + Promise.resolve().then(() => request(endpoint)), + ); + return requests.get(endpoint); + }; + const failures = []; + const verified = []; + for (const link of links.filter((item) => item.state === LinkState.BROKEN)) { + try { + const target = link.status >= 500 && link.status <= 599 ? githubTarget(link.url) : null; + if (!target) { + failures.push(link); + continue; + } + const base = `repos/${target.repository}`; + const repository = await get(base); + if ( + repository.private !== false || + repository.full_name?.toLowerCase() !== target.repository.toLowerCase() + ) + throw new Error("The target is not the expected public repository"); + if (target.kind !== "repository") { + const endpoint = { + releases: `${base}/releases`, + latest: `${base}/releases/latest`, + policy: `${base}/contents/SECURITY.md?ref=${encodeURIComponent(repository.default_branch)}`, + file: `${base}/contents/${target.path}?ref=${target.ref}`, + directory: `${base}/contents/${target.path}?ref=${target.ref}`, + }[target.kind]; + const content = await get(endpoint); + if (target.kind === "directory" || target.kind === "releases") { + if (!Array.isArray(content)) throw new Error("The target is not the expected listing"); + } else if (target.kind === "latest") { + if ( + content.draft !== false || + content.prerelease !== false || + !content.html_url?.startsWith(`https://github.com/${target.repository}/releases/tag/`) + ) + throw new Error("The target has no public stable release"); + } else if (content.type !== "file") { + throw new Error("The target is not a file"); + } + } + verified.push(link.url); + } catch (error) { + failures.push({ ...link, verificationError: error.message }); + } + } + return { failures, verified }; +} + +async function main() { + const checker = new LinkChecker(); + checker.on("retry", ({ url, secondsUntilRetry }) => + console.log(`Retrying ${url} in ${secondsUntilRetry} seconds`), + ); + const result = await checker.check({ + path: "dist", + recurse: true, + linksToSkip: ["^https://computer-mcp.github.io"], + concurrency: 5, + retryErrors: true, + retryErrorsCount: 3, + timeout: 30_000, + }); + const { failures, verified } = await resolveFailures(result.links); + for (const url of verified) console.log(`Verified through GitHub API: ${url}`); + for (const link of failures) + console.error( + `[${link.status ?? "failed"}] ${link.url}: ${link.verificationError ?? "link failed"}`, + ); + if (failures.length || (!result.passed && !verified.length)) { + process.exitCode = 1; + return; + } + console.log(`All ${result.links.length} links verified (${verified.length} through GitHub API).`); +} + +if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) + main().catch((error) => { + console.error(error.message); + process.exitCode = 1; + }); diff --git a/scripts/check-links.test.mjs b/scripts/check-links.test.mjs new file mode 100644 index 0000000..6a781dc --- /dev/null +++ b/scripts/check-links.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { githubJSON, githubTarget, resolveFailures } from "./check-links.mjs"; + +const repository = { + private: false, + full_name: "computer-mcp/computer-mcp", + default_branch: "master", +}; +const fileURL = + "https://github.com/computer-mcp/computer-mcp/blob/master/Documentation/Reference/QuickStart.md"; +const broken = (url, status = 503) => ({ url, status, state: "BROKEN" }); + +test("a GitHub service error requires a public repository and the exact file", async () => { + const endpoints = []; + const result = await resolveFailures([broken(fileURL)], async (endpoint) => { + endpoints.push(endpoint); + return endpoint === "repos/computer-mcp/computer-mcp" ? repository : { type: "file" }; + }); + assert.deepEqual(endpoints, [ + "repos/computer-mcp/computer-mcp", + "repos/computer-mcp/computer-mcp/contents/Documentation/Reference/QuickStart.md?ref=master", + ]); + assert.deepEqual(result, { failures: [], verified: [fileURL] }); +}); + +test("a missing file or failed API leaves the link failed", async () => { + const result = await resolveFailures([broken(fileURL)], async (endpoint) => { + if (endpoint === "repos/computer-mcp/computer-mcp") return repository; + throw new Error("HTTP 404"); + }); + assert.equal(result.failures.length, 1); + assert.deepEqual(result.verified, []); +}); + +test("file and directory routes retain their target type", async () => { + const directoryURL = "https://github.com/computer-mcp/computer-mcp/tree/master/Documentation"; + const result = await resolveFailures( + [broken(fileURL), broken(directoryURL)], + async (endpoint) => { + return endpoint === "repos/computer-mcp/computer-mcp" ? repository : []; + }, + ); + assert.equal(result.failures[0].url, fileURL); + assert.deepEqual(result.verified, [directoryURL]); +}); + +test("private or different repositories cannot satisfy public links", async () => { + for (const value of [ + { ...repository, private: true }, + { ...repository, full_name: "computer-mcp/another-repository" }, + ]) { + let calls = 0; + const result = await resolveFailures([broken(fileURL)], async () => { + calls += 1; + return value; + }); + assert.equal(calls, 1); + assert.equal(result.failures.length, 1); + } +}); + +test("client errors, other hosts and unsupported GitHub routes use their HTTP result", async () => { + const links = [ + broken(fileURL, 404), + broken(fileURL, 403), + broken(fileURL, 0), + broken("https://example.com/document"), + broken("https://github.com/another-owner/repository/blob/master/document.md"), + broken("https://github.com/computer-mcp/computer-mcp/issues/1"), + ]; + const result = await resolveFailures(links, () => assert.fail("Unexpected GitHub API request")); + assert.deepEqual(result.failures, links); + assert.deepEqual(result.verified, []); +}); + +test("encoded path separators and ambiguous branch routes retain HTTP validation", () => { + assert.equal( + githubTarget("https://github.com/computer-mcp/computer-mcp/blob/master/a%2Fb.md"), + null, + ); + assert.equal( + githubTarget("https://github.com/computer-mcp/computer-mcp/blob/feature/branch/a.md"), + null, + ); + assert.equal( + githubTarget("https://github.com/computer-mcp/computer-mcp/blob/master/a.md?raw=true"), + null, + ); +}); + +test("repository metadata is reused and latest releases must be public and stable", async () => { + const latest = "https://github.com/computer-mcp/computer-mcp/releases/latest"; + let metadataCalls = 0; + const result = await resolveFailures([broken(latest), broken(fileURL)], async (endpoint) => { + if (endpoint === "repos/computer-mcp/computer-mcp") { + metadataCalls += 1; + return repository; + } + if (endpoint.endsWith("/latest")) return { draft: true, prerelease: false }; + return { type: "file" }; + }); + assert.equal(metadataCalls, 1); + assert.equal(result.failures[0].url, latest); + assert.deepEqual(result.verified, [fileURL]); +}); + +test("public repository, release and policy routes require their corresponding API target", async () => { + const urls = [ + "https://github.com/computer-mcp/computer-mcp", + "https://github.com/computer-mcp/computer-mcp/releases", + "https://github.com/computer-mcp/computer-mcp/releases/latest", + "https://github.com/computer-mcp/computer-mcp/security/policy", + ]; + const responses = new Map([ + ["repos/computer-mcp/computer-mcp", repository], + ["repos/computer-mcp/computer-mcp/releases", []], + [ + "repos/computer-mcp/computer-mcp/releases/latest", + { + draft: false, + prerelease: false, + html_url: "https://github.com/computer-mcp/computer-mcp/releases/tag/example-tag", + }, + ], + ["repos/computer-mcp/computer-mcp/contents/SECURITY.md?ref=master", { type: "file" }], + ]); + const result = await resolveFailures( + urls.map((url) => broken(url)), + async (endpoint) => { + assert.ok(responses.has(endpoint)); + return responses.get(endpoint); + }, + ); + assert.deepEqual(result, { failures: [], verified: urls }); +}); + +test("API credentials are confined to the fixed GitHub API origin", async () => { + const originalToken = process.env.GH_TOKEN; + const originalFetch = globalThis.fetch; + process.env.GH_TOKEN = "test-credential"; + globalThis.fetch = async (url, options) => { + assert.equal(new URL(url).origin, "https://api.github.com"); + assert.equal(options.headers.Authorization, "Bearer test-credential"); + assert.equal(options.redirect, "error"); + return { ok: true, json: async () => repository }; + }; + try { + assert.deepEqual(await githubJSON("repos/computer-mcp/computer-mcp"), repository); + } finally { + globalThis.fetch = originalFetch; + if (originalToken === undefined) delete process.env.GH_TOKEN; + else process.env.GH_TOKEN = originalToken; + } +}); From d516e737b96aae404bf31271046d34c82133b306 Mon Sep 17 00:00:00 2001 From: showxu <10173746+showxu@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:44:57 +0800 Subject: [PATCH 3/3] State the read-only link validation boundary --- README.md | 5 +++-- scripts/check-links.mjs | 1 + scripts/check-links.test.mjs | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f66a4ea..6baf396 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,9 @@ Link validation retries transient HTTP errors with bounded concurrency. When an repository, file, directory, release or security-policy link returns a server error, it verifies the corresponding public target through the GitHub API. Client errors, missing targets and API failures still fail validation. `GH_TOKEN` or `GITHUB_TOKEN` can provide API authentication; the token is -sent only to the GitHub API. CI uses its read-only repository token. Content verification covers -`main`, `master` and full commit hashes; other content refs retain HTTP validation. +sent only to the GitHub API. Workflows use their repository token for read-only API requests. +Content verification covers `main`, `master` and full commit hashes; other content refs retain HTTP +validation. `public/release.json` binds the deployed site to the product's delivered commit and release tag. The private website package version describes this build project, not the product version. After the diff --git a/scripts/check-links.mjs b/scripts/check-links.mjs index 5bab895..6e421d9 100644 --- a/scripts/check-links.mjs +++ b/scripts/check-links.mjs @@ -35,6 +35,7 @@ export function githubTarget(link) { export async function githubJSON(endpoint) { const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; const response = await fetch(`https://api.github.com/${endpoint}`, { + method: "GET", headers: { Accept: "application/vnd.github+json", "User-Agent": "computer-mcp-website-link-check", diff --git a/scripts/check-links.test.mjs b/scripts/check-links.test.mjs index 6a781dc..e0391d8 100644 --- a/scripts/check-links.test.mjs +++ b/scripts/check-links.test.mjs @@ -141,6 +141,7 @@ test("API credentials are confined to the fixed GitHub API origin", async () => process.env.GH_TOKEN = "test-credential"; globalThis.fetch = async (url, options) => { assert.equal(new URL(url).origin, "https://api.github.com"); + assert.equal(options.method, "GET"); assert.equal(options.headers.Authorization, "Bearer test-credential"); assert.equal(options.redirect, "error"); return { ok: true, json: async () => repository };