From 709a4b1dc51ca722735ad062de2fc509b0b6d511 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 16 Jul 2026 22:11:37 -0700 Subject: [PATCH] ci: guard catalog feed schema version changes (#3138) --- .../catalog-feed-schema-version-guard.cjs | 83 ++++++++++++ ...catalog-feed-schema-version-guard.test.cjs | 32 +++++ .../catalog-feed-schema-version-guard.yml | 122 ++++++++++++++++++ specs/hosted-catalog-feed.md | 5 + 4 files changed, 242 insertions(+) create mode 100644 .github/scripts/catalog-feed-schema-version-guard.cjs create mode 100644 .github/scripts/catalog-feed-schema-version-guard.test.cjs create mode 100644 .github/workflows/catalog-feed-schema-version-guard.yml diff --git a/.github/scripts/catalog-feed-schema-version-guard.cjs b/.github/scripts/catalog-feed-schema-version-guard.cjs new file mode 100644 index 00000000..af65f33f --- /dev/null +++ b/.github/scripts/catalog-feed-schema-version-guard.cjs @@ -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 }; diff --git a/.github/scripts/catalog-feed-schema-version-guard.test.cjs b/.github/scripts/catalog-feed-schema-version-guard.test.cjs new file mode 100644 index 00000000..15570fae --- /dev/null +++ b/.github/scripts/catalog-feed-schema-version-guard.test.cjs @@ -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; + `), + ); +}); diff --git a/.github/workflows/catalog-feed-schema-version-guard.yml b/.github/workflows/catalog-feed-schema-version-guard.yml new file mode 100644 index 00000000..8ab826dd --- /dev/null +++ b/.github/workflows/catalog-feed-schema-version-guard.yml @@ -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}".`, + ); diff --git a/specs/hosted-catalog-feed.md b/specs/hosted-catalog-feed.md index d11a66af..30d45901 100644 --- a/specs/hosted-catalog-feed.md +++ b/specs/hosted-catalog-feed.md @@ -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