From 7b85b2840628d5362e029e7b390bd097060aaf55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 9 Oct 2026 22:19:38 +0000 Subject: [PATCH] fix(ci): keep the catalog publish PR when main changes nothing it generates (#5372) * fix(ci): keep the catalog publish PR when main changes nothing it generates The bot compared the whole branch tree with main, so every main push rewrote the standing PR and dismissed its approval. It now compares only generated catalog files, with search vectors judged by the drift check's cosine rule. * fix(ci): republish when main edits generated files or the branch carries others * docs(registry): motion-blur copies explain their inline styles in three lines * refactor(ci): split the kept-snapshot check into named conditions --- registry/blocks/gooey-split/gooey-split.html | 10 +- registry/components/motion-blur/demo.html | 10 +- .../components/motion-blur/motion-blur.html | 10 +- registry/components/shutter-slam/demo.html | 10 +- .../components/shutter-slam/shutter-slam.html | 10 +- scripts/catalog-drift.ts | 27 +--- scripts/catalog-publication.test.mjs | 129 +++++++++++++++--- scripts/catalog-vectors.mjs | 26 ++++ scripts/publish-catalog.mjs | 63 +++++++-- 9 files changed, 210 insertions(+), 85 deletions(-) create mode 100644 scripts/catalog-vectors.mjs diff --git a/registry/blocks/gooey-split/gooey-split.html b/registry/blocks/gooey-split/gooey-split.html index b4037d1081..adbafc9532 100644 --- a/registry/blocks/gooey-split/gooey-split.html +++ b/registry/blocks/gooey-split/gooey-split.html @@ -280,13 +280,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/motion-blur/demo.html b/registry/components/motion-blur/demo.html index 025e427ffe..85a8f83476 100644 --- a/registry/components/motion-blur/demo.html +++ b/registry/components/motion-blur/demo.html @@ -402,13 +402,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/motion-blur/motion-blur.html b/registry/components/motion-blur/motion-blur.html index aebd983b8f..17f6374392 100644 --- a/registry/components/motion-blur/motion-blur.html +++ b/registry/components/motion-blur/motion-blur.html @@ -271,13 +271,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/shutter-slam/demo.html b/registry/components/shutter-slam/demo.html index 9aa2ced4d2..59f4e71a2c 100644 --- a/registry/components/shutter-slam/demo.html +++ b/registry/components/shutter-slam/demo.html @@ -232,13 +232,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/registry/components/shutter-slam/shutter-slam.html b/registry/components/shutter-slam/shutter-slam.html index e74355141b..b2cd02b33e 100644 --- a/registry/components/shutter-slam/shutter-slam.html +++ b/registry/components/shutter-slam/shutter-slam.html @@ -266,13 +266,9 @@ var copies = samples + 1; var alpha = String(1 / samples); - // A copy is styled by nothing that selected the original: dropping the id also - // drops every `#id` rule that gave it size, colour and font. So each copy carries - // its own resolved style inline. Chrome returns "" for a computed style's cssText, - // so the declarations are enumerated rather than taken wholesale. - // Visibility is not frozen into a copy: its root takes the element's current one from - // the group each frame, and a descendant keeps its own only where it differs from its - // parent's. A target hidden at attach (autoAlpha) would otherwise never smear. + // A copy loses the `#id` rules that styled the original, so it carries its resolved style inline + // (enumerated: Chrome returns "" for a computed cssText). Visibility is not frozen: the root + // follows the group each frame, so a target hidden at attach still smears. function resolvedStyles(source, out, parentVisibility) { var cs = getComputedStyle(source); var text = ""; diff --git a/scripts/catalog-drift.ts b/scripts/catalog-drift.ts index 76f0ffdeb6..2870390f3b 100644 --- a/scripts/catalog-drift.ts +++ b/scripts/catalog-drift.ts @@ -6,13 +6,11 @@ import { join, relative, resolve, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { generateCatalog } from "./generate-catalog.ts"; import { GENERATED_CATALOG_PATHS } from "./catalog-generated-paths.mjs"; +import { VECTORS, vectorsAgree } from "./catalog-vectors.mjs"; import { runAsCommand } from "./entrypoint.ts"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const MAX_LISTED = 40; -// The embedding model's float output differs slightly between CPUs, so vectors are compared by direction, not bytes. -const VECTORS = /-vectors\.bin$/; -const MIN_ROW_COSINE = 0.99; function filesUnder(root: string): string[] { return readdirSync(root, { recursive: true, withFileTypes: true }) @@ -47,29 +45,6 @@ function sameContent(generatedRoot: string, committedRoot: string, file: string) return vectorsAgree(generated, committed, dimensions); } -/** Same row count, and every row of Float32 vectors points the same way within MIN_ROW_COSINE. */ -export function vectorsAgree(a: Buffer, b: Buffer, dimensions: number): boolean { - const rowBytes = 4 * dimensions; - if (a.length !== b.length || a.length % rowBytes !== 0) return false; - return Array.from({ length: a.length / rowBytes }, (_, row) => - rowCosine(a, b, row * rowBytes, (row + 1) * rowBytes), - ).every((cosine) => cosine >= MIN_ROW_COSINE); -} - -function rowCosine(a: Buffer, b: Buffer, start: number, end: number): number { - let dot = 0; - let normA = 0; - let normB = 0; - for (let i = start; i < end; i += 4) { - const x = a.readFloatLE(i); - const y = b.readFloatLE(i); - dot += x * y; - normA += x * x; - normB += y * y; - } - return dot / Math.sqrt(normA * normB); -} - async function main(): Promise { await generateCatalog(); for (const script of [ diff --git a/scripts/catalog-publication.test.mjs b/scripts/catalog-publication.test.mjs index 92abc36032..30a556d831 100644 --- a/scripts/catalog-publication.test.mjs +++ b/scripts/catalog-publication.test.mjs @@ -1,6 +1,14 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; @@ -93,19 +101,10 @@ function publicationFixture(t, changed, apiEnv = {}) { mkdirSync(dirname(join(root, target)), { recursive: true }); writeFileSync(join(root, target), "published"); } - git("add", "."); - git( - "-c", - "user.name=Test", - "-c", - "user.email=test@example.invalid", - "-c", - "commit.gpgsign=false", - "commit", - "-qm", - "fixture", - ); - const base = git("rev-parse", "HEAD"); + const base = commitPaths(git, "fixture", ["."]); + git("init", "-q", "--bare", "origin.git"); + git("remote", "add", "origin", join(root, "origin.git")); + git("push", "-q", "origin", "HEAD:refs/heads/bot/catalog-publish"); if (changed) writeFileSync(join(root, "registry/registry.json"), "new publication"); const executable = join(root, "gh"); writeFileSync( @@ -127,7 +126,6 @@ else if (endpoint.endsWith("/dispatches") && process.env.DISPATCH_FAIL) process. else if (endpoint.endsWith("/ref/heads/main")) console.log(process.env.BASE); else if (endpoint.includes("/matching-refs/")) console.log("refs/heads/bot/catalog-publish"); else if (endpoint.includes("/ref/heads/bot/catalog-publish")) console.log("a".repeat(40)); -else if (endpoint.includes("/commits/")) console.log("b".repeat(40)); else if (endpoint.includes("/pulls?")) console.log("42"); `, { mode: 0o755 }, @@ -153,7 +151,7 @@ catch (error) { env: { ...process.env, PATH: `${root}:${process.env.PATH}`, - BASE: base, + BASE: git("rev-parse", "HEAD"), API_CALLS: calls, FIXTURE_ROOT: root, GITHUB_REPOSITORY: "test/catalog", @@ -167,6 +165,7 @@ catch (error) { return { root, base, + git, run, calls: () => readFileSync(calls, "utf8") @@ -328,3 +327,101 @@ test("regression accepts completed shards after successful change detection", () const result = runSummary("success", "true", "success"); assert.equal(result.status, 0, result.stdout + result.stderr); }); + +function commitPaths(git, message, paths) { + git("add", "-A", "--", ...paths); + git( + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-qm", + message, + ); + return git("rev-parse", "HEAD"); +} + +const VECTOR_META = "registry/catalog-artifact/local-vectors.json"; +const VECTOR_BIN = "registry/catalog-artifact/local-vectors.bin"; +// Two rows of two dimensions. +const vectors = (...values) => Buffer.from(new Float32Array(values).buffer); + +// Publishes the working tree's files at `paths` and leaves them in place, as the next run would regenerate them. +function publish(fixture, paths = GENERATED_CATALOG_PATHS) { + commitPaths(fixture.git, "publication", paths); + fixture.git("push", "-q", "-f", "origin", "HEAD:bot/catalog-publish"); + fixture.git("reset", "-q", "HEAD~"); +} + +function commitToMain(fixture, path, contents) { + const kept = existsSync(join(fixture.root, path)) + ? readFileSync(join(fixture.root, path)) + : undefined; + writeFileSync(join(fixture.root, path), contents); + commitPaths(fixture.git, "main push", [path]); + if (kept !== undefined) writeFileSync(join(fixture.root, path), kept); +} + +const rewrites = (fixture) => + fixture + .calls() + .some((call) => call.endpoint === "graphql" || call.endpoint.includes("/refs/heads/bot/")); + +function vectorFixture(t) { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + writeFileSync(join(fixture.root, VECTOR_META), JSON.stringify({ dimensions: 2 })); + writeFileSync(join(fixture.root, VECTOR_BIN), vectors(1, 0, 0, 1)); + return fixture; +} + +test("an unrelated main push leaves the approved publication alone", (t) => { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + publish(fixture); + commitToMain(fixture, "notes.txt", "unrelated"); + fixture.run(); + assert.equal(rewrites(fixture), false); +}); + +test("regenerated vectors replace the publication only when they point elsewhere", (t) => { + for (const [regenerated, rewritten] of [ + [vectors(1, 0.001, 0.001, 1), false], + [vectors(1, 0, 1, 0), true], + ]) { + const fixture = vectorFixture(t); + publish(fixture); + commitToMain(fixture, "notes.txt", "unrelated"); + writeFileSync(join(fixture.root, VECTOR_BIN), regenerated); + fixture.run(); + assert.equal(rewrites(fixture), rewritten); + } +}); + +test("vectors the publication lacks replace it", (t) => { + const fixture = vectorFixture(t); + rmSync(join(fixture.root, VECTOR_BIN)); + publish(fixture); + commitToMain(fixture, "notes.txt", "unrelated"); + writeFileSync(join(fixture.root, VECTOR_BIN), vectors(1, 0, 0, 1)); + fixture.run(); + assert.equal(rewrites(fixture), true); +}); + +test("a main commit to a generated file replaces the publication", (t) => { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + publish(fixture); + commitToMain(fixture, "registry/registry.json", "edited on main"); + fixture.run(); + assert.equal(rewrites(fixture), true); +}); + +test("a publication carrying anything but generated files is replaced", (t) => { + const fixture = publicationFixture(t, true, { PUBLISH_SUCCESS: "1" }); + writeFileSync(join(fixture.root, "extra.sh"), "not generated"); + publish(fixture, [...GENERATED_CATALOG_PATHS, "extra.sh"]); + commitToMain(fixture, "notes.txt", "unrelated"); + fixture.run(); + assert.equal(rewrites(fixture), true); +}); diff --git a/scripts/catalog-vectors.mjs b/scripts/catalog-vectors.mjs new file mode 100644 index 0000000000..007b6548b3 --- /dev/null +++ b/scripts/catalog-vectors.mjs @@ -0,0 +1,26 @@ +// The embedding model's float output differs slightly between CPUs, so vectors are compared by direction, not bytes. +export const VECTORS = /-vectors\.bin$/; +const MIN_ROW_COSINE = 0.99; + +/** Same row count, and every row of Float32 vectors points the same way within MIN_ROW_COSINE. */ +export function vectorsAgree(a, b, dimensions) { + const rowBytes = 4 * dimensions; + if (a.length !== b.length || a.length % rowBytes !== 0) return false; + return Array.from({ length: a.length / rowBytes }, (_, row) => + rowCosine(a, b, row * rowBytes, (row + 1) * rowBytes), + ).every((cosine) => cosine >= MIN_ROW_COSINE); +} + +function rowCosine(a, b, start, end) { + let dot = 0; + let normA = 0; + let normB = 0; + for (let i = start; i < end; i += 4) { + const x = a.readFloatLE(i); + const y = b.readFloatLE(i); + dot += x * y; + normA += x * x; + normB += y * y; + } + return dot / Math.sqrt(normA * normB); +} diff --git a/scripts/publish-catalog.mjs b/scripts/publish-catalog.mjs index 3c73e5e97b..00c8cf5c12 100644 --- a/scripts/publish-catalog.mjs +++ b/scripts/publish-catalog.mjs @@ -12,6 +12,7 @@ import { join, resolve } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { GENERATED_CATALOG_PATHS } from "./catalog-generated-paths.mjs"; +import { VECTORS, vectorsAgree } from "./catalog-vectors.mjs"; const BRANCH = "bot/catalog-publish"; const TITLE = "chore(catalog): publish generated catalog"; @@ -249,13 +250,58 @@ function clearObsoletePublication(repository, base, exists) { console.log("No unpublished catalog changes; obsolete publication cleared."); } -function snapshotMatches(root, endpoint, exists) { - if (!exists) return false; - const previous = commitOid( - api(`${endpoint}/ref/heads/${BRANCH}`, "GET", undefined, ".object.sha"), +// The base of the published snapshot while it still matches what main generates: rewriting the branch for an +// unrelated main push would dismiss its approval. +function keptSnapshotBase(root, exists) { + if (!exists) return undefined; + git(root, ["fetch", "-q", "--no-tags", "origin", BRANCH]); + const published = git(root, ["rev-parse", "FETCH_HEAD"]); + const forkPoint = git(root, ["merge-base", published, "HEAD"]); + const current = onlyGeneratedSince(root, forkPoint, published) && sameGenerated(root, published); + return current ? forkPoint : undefined; +} + +// Main edited no generated file since the fork, and the branch carries nothing else. +function onlyGeneratedSince(root, forkPoint, published) { + const elsewhere = GENERATED_CATALOG_PATHS.map((path) => `:(exclude)${path}`); + return ( + !git(root, ["diff", "--name-only", forkPoint, "HEAD", "--", ...GENERATED_CATALOG_PATHS]) && + !git(root, ["diff", "--name-only", forkPoint, published, "--", ".", ...elsewhere]) ); - const tree = commitOid(api(`${endpoint}/commits/${previous}`, "GET", undefined, ".tree.sha")); - return tree === catalogTree(root); +} + +function sameGenerated(root, published) { + const generated = catalogTree(root); + const changes = git(root, [ + "diff", + "--name-status", + "--no-renames", + "-z", + published, + generated, + "--", + ...GENERATED_CATALOG_PATHS, + ]) + .split("\0") + .filter(Boolean); + const pairs = Array.from({ length: changes.length / 2 }, (_, i) => + changes.slice(2 * i, 2 * i + 2), + ); + return pairs.every( + ([status, path]) => + status === "M" && VECTORS.test(path) && sameVectors(root, published, generated, path), + ); +} + +function sameVectors(root, published, generated, path) { + const blob = (tree, file) => + execFileSync("git", ["cat-file", "blob", `${tree}:${file}`], { + cwd: root, + maxBuffer: 64 * 1024 * 1024, + }); + const meta = path.replace(/\.bin$/, ".json"); + const { dimensions } = JSON.parse(blob(generated, meta).toString("utf8")); + return vectorsAgree(blob(published, path), blob(generated, path), dimensions); } function cleanStagingBranch(endpoint, staging, errors) { @@ -303,8 +349,9 @@ export function publish(root) { ); const exists = refs.includes(`refs/heads/${BRANCH}`); if (batches.length === 0) return clearObsoletePublication(repository, base, exists); - if (snapshotMatches(root, endpoint, exists)) { - openPublishPr(repository, base); + const kept = keptSnapshotBase(root, exists); + if (kept) { + openPublishPr(repository, kept); dispatchPublishChecks(repository); console.log("Standing catalog PR already contains this snapshot."); return;