openclaw/scripts/docs-mirror-freshness.mjs
Peter Steinberger ac5ba7d738
fix(docs): detect overdue mirror changes during steady edits (#143455)
* fix(docs): detect overdue mirror changes during steady edits

* test(docs): include mirror regression in workflow routing
2026-09-09 17:14:45 -07:00

198 lines
6.7 KiB
JavaScript

#!/usr/bin/env node
// Alerts when the openclaw/docs mirror is stale relative to docs-touching
// commits on main. Reads the watched paths from docs-sync-publish.yml itself so
// the staleness definition can never drift from the sync trigger's path filters.
// On staleness it dispatches one recovery sync, then exits nonzero so the
// scheduled run fails and notifies. Runner: .github/workflows/docs-mirror-freshness.yml.
import fs from "node:fs";
import process from "node:process";
const TOOL = "docs-mirror-freshness";
const SOURCE_REPO = process.env.GITHUB_REPOSITORY || "openclaw/openclaw";
// Publish-repo contract: docs mirror to openclaw/docs (root AGENTS.md, Docs section).
const MIRROR_REPO = "openclaw/docs";
const SYNC_WORKFLOW = "docs-sync-publish.yml";
const SYNC_WORKFLOW_FILE = `.github/workflows/${SYNC_WORKFLOW}`;
const STALE_MINUTES = Number(process.env.DOCS_MIRROR_STALE_MINUTES ?? "60");
function fail(message) {
console.error(message);
console.error(`[${TOOL}] FAILED (exit 1)`);
process.exit(1);
}
async function githubJson(apiPath, init = {}) {
const headers = {
accept: "application/vnd.github+json",
"user-agent": TOOL,
...init.headers,
};
if (process.env.GITHUB_TOKEN) {
headers.authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
}
const response = await fetch(`https://api.github.com${apiPath}`, { ...init, headers });
if (!response.ok) {
throw new Error(`GitHub ${init.method ?? "GET"} ${apiPath} responded ${response.status}`);
}
return response.status === 204 ? null : await response.json();
}
function readSyncWatchPaths(workflowText) {
const paths = [];
let inPush = false;
let inPaths = false;
for (const line of workflowText.split("\n")) {
if (/^ {2}push:\s*$/.test(line)) {
inPush = true;
continue;
}
if (inPush && /^ {0,2}\S/.test(line)) {
inPush = false;
inPaths = false;
}
if (inPush && /^ {4}paths:\s*$/.test(line)) {
inPaths = true;
continue;
}
if (inPaths) {
const item = line.match(/^\s+-\s+(\S+)\s*$/);
if (item) {
paths.push(item[1]);
continue;
}
inPaths = false;
}
}
if (paths.length === 0) {
throw new Error(
`no push path filters parsed from ${SYNC_WORKFLOW_FILE}; parser needs updating`,
);
}
return paths;
}
// The commits API takes plain file/directory paths, so only the trailing-glob
// directory form used by the sync trigger is translatable; any other glob means
// the filter shape changed and this check must be updated, not silently skipped.
function toCommitApiPath(pattern) {
const bare = pattern.endsWith("/**") ? pattern.slice(0, -3) : pattern;
if (/[*?[\]]/.test(bare)) {
throw new Error(`unsupported glob in sync path filter: ${pattern}`);
}
return bare;
}
async function newestWatchedCommit(watchPaths, until = "") {
let newest = null;
const untilQuery = until ? `&until=${encodeURIComponent(until)}` : "";
for (const watchPath of watchPaths) {
const commits = await githubJson(
`/repos/${SOURCE_REPO}/commits?sha=main&path=${encodeURIComponent(watchPath)}&per_page=1${untilQuery}`,
);
const head = commits[0];
if (!head) {
continue;
}
const committedAt = Date.parse(head.commit.committer.date);
if (!newest || committedAt > newest.committedAt) {
newest = { sha: head.sha, committedAt, path: watchPath };
}
}
if (!newest && !until) {
throw new Error("no commits found for any watched docs path on main");
}
return newest;
}
async function readMirroredSourceSha() {
const contents = await githubJson(
`/repos/${MIRROR_REPO}/contents/.openclaw-sync/source.json?ref=main`,
);
const source = JSON.parse(Buffer.from(contents.content, "base64").toString("utf8"));
if (typeof source.sha !== "string" || !/^[0-9a-f]{40}$/.test(source.sha)) {
throw new Error(`mirror source.json has no valid source sha: ${JSON.stringify(source.sha)}`);
}
return source.sha;
}
async function mirrorCoversCommit(commitSha, mirroredSha) {
if (commitSha === mirroredSha) {
return true;
}
const comparison = await githubJson(
`/repos/${SOURCE_REPO}/compare/${commitSha}...${mirroredSha}`,
);
// "ahead"/"identical": the mirrored sha contains the commit. "behind" or
// "diverged" (mirror off main history) both count as not covered.
return comparison.status === "identical" || comparison.status === "ahead";
}
async function countActiveSyncRuns() {
let active = 0;
for (const status of ["queued", "in_progress"]) {
const runs = await githubJson(
`/repos/${SOURCE_REPO}/actions/workflows/${SYNC_WORKFLOW}/runs?status=${status}&per_page=1`,
);
active += runs.total_count;
}
return active;
}
async function dispatchRecoverySync() {
await githubJson(`/repos/${SOURCE_REPO}/actions/workflows/${SYNC_WORKFLOW}/dispatches`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ref: "main" }),
});
}
async function main() {
const watchPaths = readSyncWatchPaths(fs.readFileSync(SYNC_WORKFLOW_FILE, "utf8")).map(
toCommitApiPath,
);
const [newest, mirroredSha] = await Promise.all([
newestWatchedCommit(watchPaths),
readMirroredSourceSha(),
]);
const now = Date.now();
const ageMinutes = Math.round((now - newest.committedAt) / 60_000);
const summary = `newest docs-touching main commit ${newest.sha} (${newest.path}, ${ageMinutes}m ago); mirror at ${mirroredSha}`;
if (await mirrorCoversCommit(newest.sha, mirroredSha)) {
console.log(`docs mirror fresh: ${summary}`);
return;
}
let stale = newest;
const cutoff = now - STALE_MINUTES * 60_000;
if (newest.committedAt > cutoff) {
// Recent edits must not renew grace for older changes the mirror still lacks.
stale = await newestWatchedCommit(watchPaths, new Date(cutoff).toISOString());
if (!stale || (await mirrorCoversCommit(stale.sha, mirroredSha))) {
console.log(
`docs mirror trailing within ${STALE_MINUTES}m grace (sync in flight?): ${summary}`,
);
return;
}
}
const activeSyncRuns = await countActiveSyncRuns();
if (activeSyncRuns === 0) {
await dispatchRecoverySync();
console.error(`dispatched ${SYNC_WORKFLOW} recovery run on main`);
} else {
console.error(
`${activeSyncRuns} ${SYNC_WORKFLOW} run(s) already queued/in progress; not dispatching another`,
);
}
const staleMinutes = Math.round((now - stale.committedAt) / 60_000);
fail(
`docs mirror stale for ${staleMinutes}m (threshold ${STALE_MINUTES}m): unmirrored docs-touching main commit ${stale.sha} (${stale.path}); mirror at ${mirroredSha}`,
);
}
try {
await main();
} catch (error) {
fail(`${TOOL} error: ${error instanceof Error ? error.message : String(error)}`);
}