mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 00:47:57 +00:00
ci: guard catalog feed schema version changes (#3138)
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
function maskNonCode(source) {
|
||||
let result = "";
|
||||
let state = "code";
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
const next = source[index + 1];
|
||||
if (state === "line-comment") {
|
||||
if (char === "\n") {
|
||||
state = "code";
|
||||
result += char;
|
||||
} else {
|
||||
result += " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state === "block-comment") {
|
||||
if (char === "*" && next === "/") {
|
||||
result += " ";
|
||||
index += 1;
|
||||
state = "code";
|
||||
} else {
|
||||
result += char === "\n" ? "\n" : " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (state !== "code") {
|
||||
if (char === "\\") {
|
||||
result += " ";
|
||||
if (next !== undefined) {
|
||||
result += next === "\n" ? "\n" : " ";
|
||||
index += 1;
|
||||
}
|
||||
} else if (
|
||||
(state === "single-quote" && char === "'") ||
|
||||
(state === "double-quote" && char === '"') ||
|
||||
(state === "template" && char === "`")
|
||||
) {
|
||||
result += " ";
|
||||
state = "code";
|
||||
} else {
|
||||
result += char === "\n" ? "\n" : " ";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "/") {
|
||||
result += " ";
|
||||
index += 1;
|
||||
state = "line-comment";
|
||||
} else if (char === "/" && next === "*") {
|
||||
result += " ";
|
||||
index += 1;
|
||||
state = "block-comment";
|
||||
} else if (char === "'") {
|
||||
result += " ";
|
||||
state = "single-quote";
|
||||
} else if (char === '"') {
|
||||
result += " ";
|
||||
state = "double-quote";
|
||||
} else if (char === "`") {
|
||||
result += " ";
|
||||
state = "template";
|
||||
} else {
|
||||
result += char;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractCatalogFeedSchemaVersion(source) {
|
||||
const matches = [
|
||||
...maskNonCode(source).matchAll(
|
||||
/^\s*export\s+const\s+CATALOG_FEED_SCHEMA_VERSION\s*=\s*(\d+)\s*;/gm,
|
||||
),
|
||||
];
|
||||
if (matches.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one CATALOG_FEED_SCHEMA_VERSION declaration, found ${matches.length}`,
|
||||
);
|
||||
}
|
||||
return Number(matches[0][1]);
|
||||
}
|
||||
|
||||
module.exports = { extractCatalogFeedSchemaVersion };
|
||||
@@ -0,0 +1,32 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const { extractCatalogFeedSchemaVersion } = require("./catalog-feed-schema-version-guard.cjs");
|
||||
|
||||
test("reads the exported catalog feed schema version", () => {
|
||||
assert.equal(
|
||||
extractCatalogFeedSchemaVersion("export const CATALOG_FEED_SCHEMA_VERSION = 1;\n"),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores fake declarations in comments and strings", () => {
|
||||
assert.equal(
|
||||
extractCatalogFeedSchemaVersion(`
|
||||
// export const CATALOG_FEED_SCHEMA_VERSION = 7;
|
||||
/* export const CATALOG_FEED_SCHEMA_VERSION = 8; */
|
||||
const example = "export const CATALOG_FEED_SCHEMA_VERSION = 9;";
|
||||
export const CATALOG_FEED_SCHEMA_VERSION = 2;
|
||||
`),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("rejects missing or duplicate declarations", () => {
|
||||
assert.throws(() => extractCatalogFeedSchemaVersion("const version = 1;"));
|
||||
assert.throws(() =>
|
||||
extractCatalogFeedSchemaVersion(`
|
||||
export const CATALOG_FEED_SCHEMA_VERSION = 1;
|
||||
export const CATALOG_FEED_SCHEMA_VERSION = 2;
|
||||
`),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
name: Catalog Feed Schema Version Guard
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
schema-version:
|
||||
name: schema-version
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Test schema-version parser
|
||||
run: node --test .github/scripts/catalog-feed-schema-version-guard.test.cjs
|
||||
|
||||
- name: Require explicit approval for catalog feed schema bumps
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const {
|
||||
extractCatalogFeedSchemaVersion,
|
||||
} = require("./.github/scripts/catalog-feed-schema-version-guard.cjs");
|
||||
const path = "packages/schema/src/catalogFeed.ts";
|
||||
const approvalLabel = "schema-version-approved";
|
||||
const pull = context.payload.pull_request;
|
||||
|
||||
async function readVersion({ owner, repo, ref }) {
|
||||
const response = await github.rest.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
ref,
|
||||
});
|
||||
if (Array.isArray(response.data) || response.data.type !== "file") {
|
||||
throw new Error(`Expected ${path} to be a file at ${owner}/${repo}@${ref}`);
|
||||
}
|
||||
const source = Buffer.from(response.data.content, "base64").toString("utf8");
|
||||
return extractCatalogFeedSchemaVersion(source);
|
||||
}
|
||||
|
||||
const comparison = await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
basehead: `${pull.base.sha}...${pull.head.sha}`,
|
||||
});
|
||||
const baseVersion = await readVersion({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
ref: comparison.data.merge_base_commit.sha,
|
||||
});
|
||||
let headVersion;
|
||||
try {
|
||||
headVersion = await readVersion({
|
||||
owner: pull.head.repo.owner.login,
|
||||
repo: pull.head.repo.name,
|
||||
ref: pull.head.sha,
|
||||
});
|
||||
} catch (error) {
|
||||
core.setFailed(
|
||||
`${path} must remain at its canonical location: ${error.message}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (headVersion === baseVersion) {
|
||||
core.info(`Catalog feed schema version remains ${baseVersion}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const labels = new Set(pull.labels.map((label) => label.name));
|
||||
if (
|
||||
context.payload.action === "synchronize" &&
|
||||
labels.has(approvalLabel)
|
||||
) {
|
||||
const previousHead = context.payload.before;
|
||||
let previousVersion;
|
||||
try {
|
||||
previousVersion = previousHead
|
||||
? await readVersion({
|
||||
owner: pull.head.repo.owner.login,
|
||||
repo: pull.head.repo.name,
|
||||
ref: previousHead,
|
||||
})
|
||||
: undefined;
|
||||
} catch {
|
||||
previousVersion = undefined;
|
||||
}
|
||||
if (previousVersion !== headVersion) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: pull.number,
|
||||
name: approvalLabel,
|
||||
});
|
||||
core.setFailed(
|
||||
`The latest commit changed the catalog feed schema version. The stale "${approvalLabel}" label was removed; re-add it only after the current head ${pull.head.sha} receives explicit approval.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!labels.has(approvalLabel)) {
|
||||
core.setFailed(
|
||||
[
|
||||
`Catalog feed schema version changed from ${baseVersion} to ${headVersion}.`,
|
||||
`Add the "${approvalLabel}" label only after explicit approval.`,
|
||||
"A schema-version change is a cross-repo wire-contract change and requires matching OpenClaw parser and validation updates before ClawHub publishes it.",
|
||||
].join(" "),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
core.notice(
|
||||
`Catalog feed schema version changed from ${baseVersion} to ${headVersion} with explicit approval via "${approvalLabel}".`,
|
||||
);
|
||||
Reference in New Issue
Block a user