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
This commit is contained in:
Miguel Ángel
2026-10-09 22:19:38 +00:00
committed by GitHub
parent 9460c983e0
commit 7b85b28406
9 changed files with 210 additions and 85 deletions
+3 -7
View File
@@ -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 = "";
+3 -7
View File
@@ -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 = "";
+3 -7
View File
@@ -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 = "";
+3 -7
View File
@@ -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 = "";
+3 -7
View File
@@ -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 = "";
+1 -26
View File
@@ -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<void> {
await generateCatalog();
for (const script of [
+113 -16
View File
@@ -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);
});
+26
View File
@@ -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);
}
+55 -8
View File
@@ -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;