mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-12 03:20:40 +00:00
* fix(docs): detect overdue mirror changes during steady edits * test(docs): include mirror regression in workflow routing
198 lines
6.7 KiB
JavaScript
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)}`);
|
|
}
|