ci(i18n): enforce controlled crowdin localization workflow and pr validation

Add Crowdin pull request validation script, centralize locale configuration, and update GitHub workflows to enforce sparse target catalog validation.
This commit is contained in:
neon
2026-08-06 00:00:04 +00:00
parent 6a0a9a3d53
commit b9e041d1b7
16 changed files with 608 additions and 71 deletions
+115
View File
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
const scriptPath = fileURLToPath(new URL("./validate-locale-pr.mjs", import.meta.url));
const gitEnv = {
...process.env,
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_AUTHOR_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
};
const git = (cwd, args) =>
execFileSync("git", args, {
cwd,
encoding: "utf8",
env: gitEnv,
stdio: ["ignore", "pipe", "pipe"],
}).trim();
const createRepository = () => {
const cwd = mkdtempSync(path.join(tmpdir(), "bookorbit-locale-policy-"));
git(cwd, ["init", "--quiet"]);
mkdirSync(path.join(cwd, "client/src/locales"), { recursive: true });
writeFileSync(path.join(cwd, "client/src/locales/en.json"), '{"example":"Example"}\n');
writeFileSync(path.join(cwd, "client/src/locales/de.json"), '{"example":"Beispiel"}\n');
git(cwd, ["add", "."]);
git(cwd, ["commit", "--quiet", "-m", "base"]);
return cwd;
};
const commitChanges = (cwd) => {
git(cwd, ["add", "--all"]);
git(cwd, ["commit", "--quiet", "-m", "change"]);
};
const validate = (cwd, isCrowdin = false) => {
const headSha = git(cwd, ["rev-parse", "HEAD"]);
const baseSha = git(cwd, ["rev-parse", "HEAD^"]);
return spawnSync(process.execPath, [scriptPath, baseSha, headSha, String(isCrowdin)], { cwd, encoding: "utf8" });
};
const withRepository = (callback) => {
const cwd = createRepository();
try {
callback(cwd);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
};
test("allows an ordinary pull request to change en.json", () => {
withRepository((cwd) => {
writeFileSync(path.join(cwd, "client/src/locales/en.json"), '{"example":"Updated"}\n');
commitChanges(cwd);
assert.equal(validate(cwd).status, 0);
});
});
test("rejects an ordinary pull request that changes a target catalog", () => {
withRepository((cwd) => {
writeFileSync(path.join(cwd, "client/src/locales/de.json"), '{"example":"Geandert"}\n');
commitChanges(cwd);
const result = validate(cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /Crowdin-owned target catalog/);
assert.match(result.stderr, /client\/src\/locales\/en\.json/);
});
});
test("rejects deletion of an existing target catalog", () => {
withRepository((cwd) => {
rmSync(path.join(cwd, "client/src/locales/de.json"));
commitChanges(cwd);
assert.equal(validate(cwd).status, 1);
});
});
test("allows an empty catalog for new-language setup", () => {
withRepository((cwd) => {
writeFileSync(path.join(cwd, "client/src/locales/ja.json"), "{}\n");
commitChanges(cwd);
assert.equal(validate(cwd).status, 0);
});
});
test("rejects a translated catalog added outside Crowdin", () => {
withRepository((cwd) => {
writeFileSync(path.join(cwd, "client/src/locales/ja.json"), '{"example":"Rei"}\n');
commitChanges(cwd);
const result = validate(cwd);
assert.equal(result.status, 1);
assert.match(result.stderr, /new target catalog but is not empty/);
});
});
test("allows a classified Crowdin pull request to change target catalogs", () => {
withRepository((cwd) => {
writeFileSync(path.join(cwd, "client/src/locales/de.json"), '{"example":"Aktualisiert"}\n');
commitChanges(cwd);
assert.equal(validate(cwd, true).status, 0);
});
});