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
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
const [baseSha, headSha, isCrowdin] = process.argv.slice(2);
if (!baseSha || !headSha || !["true", "false"].includes(isCrowdin)) {
console.error("usage: validate-locale-pr.mjs <base-sha> <head-sha> <is-crowdin>");
process.exit(2);
}
if (isCrowdin === "true") {
process.exit(0);
}
const git = (args, options = {}) =>
execFileSync("git", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
...options,
});
const pathExists = (revision, path) => {
try {
git(["cat-file", "-e", `${revision}:${path}`]);
return true;
} catch {
return false;
}
};
const changedPaths = git(["diff", "--name-only", "-z", baseSha, headSha]).split("\0").filter(Boolean);
const targetCatalogs = changedPaths.filter((path) => /^client\/src\/locales\/[^/]+\.json$/.test(path) && path !== "client/src/locales/en.json");
const errors = [];
for (const path of targetCatalogs) {
if (pathExists(baseSha, path)) {
errors.push(`${path} is a Crowdin-owned target catalog and cannot be changed in an ordinary pull request.`);
continue;
}
if (!pathExists(headSha, path)) {
errors.push(`${path} cannot be deleted or renamed in an ordinary pull request.`);
continue;
}
const contents = git(["show", `${headSha}:${path}`]);
let catalog;
try {
catalog = JSON.parse(contents);
} catch {
errors.push(`${path} must be valid JSON.`);
continue;
}
const isEmptyObject = catalog !== null && typeof catalog === "object" && !Array.isArray(catalog) && Object.keys(catalog).length === 0;
if (!isEmptyObject) {
errors.push(`${path} is a new target catalog but is not empty. New languages must start with an empty catalog before Crowdin export is enabled.`);
}
}
if (errors.length > 0) {
for (const error of errors) {
console.error(`::error::${error}`);
}
console.error(
"::error::Add or update application copy only in client/src/locales/en.json. Apply translations in Crowdin and let the controlled translation workflow update target catalogs.",
);
process.exit(1);
}
+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);
});
});