ci: guard catalog feed schema version changes (#3138)

This commit is contained in:
Patrick Erichsen
2026-07-16 22:11:37 -07:00
committed by GitHub
parent 246bcb027d
commit 709a4b1dc5
4 changed files with 242 additions and 0 deletions
@@ -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}".`,
);
+5
View File
@@ -31,6 +31,11 @@ catalogs.
consumer. Do not bump it until matching OpenClaw parser and validation support
has shipped, or current clients will reject the hosted feed and fall back to
bundled data.
Any pull request changing `CATALOG_FEED_SCHEMA_VERSION` must carry the
`schema-version-approved` label, added only after explicit approval confirms
that the matching OpenClaw parser and validation work is coordinated. A new
commit that changes the schema version automatically removes the label so the
current revision must be approved again.
The producer excludes soft-deleted packages, inactive releases, releases without
an artifact digest, and releases blocked by ClawHub security or moderation