feat(scripts): sync-sched-publish.mjs — полный синк sched→GitHub с pre-push CI-гейтом

Алгоритм (ранбук wiki:3332, один коммит на волну):
collect → copy → bleach → scrub (скраб kzntsv в т.ч. \u002F) →
lock (yarn.lock против npmjs, gotcha 8) → gate (build 9 пакетов топо-порядком
+ tsc + vitest прямыми вызовами node_modules, обход Windows yarn-bin-болезни)
→ один чистый коммит (--amend при фиксе) → push.

Фикс бага: pairs были массивом строк, деструктуризация [old,nw] давала
односимвольные замены (g→i, r→e — ломало dist); теперь пары [old,nw].
Идемпотентен: повторный прогон = 0 изменений.
This commit is contained in:
2026-08-31 08:55:38 +03:00
parent dcfb420f73
commit cbffb4a0ca

View File

@@ -0,0 +1,334 @@
// sched → GitHub: полный синк курируемой копии (ранбук wiki:3332, task:1711).
//
// Алгоритм (один коммит на волну, pre-push CI-эквивалентный гейт):
// collect — ls-files dev минус исключения (.wiki/.tasks/.agents/AGENTS/CLAUDE/
// branding-pora/pngtree/327274997/cat-clock/MIGRATION)
// copy — mkdir -p + cp в pub (untracked dev-мусор не копируется)
// bleach — dev-приватное → публичное (yarnrc/Dockerfile/compose/nuxt/
// sched-links/publish-check/CHANGELOG/examples)
// scrub — dist: rm -rf + cp из dev + скраб git.kzntsv (в т.ч. \u002F и \\u002F)
// lock — yarn.lock регенерация против npmjs (gotcha 8): если
// `yarn install --immutable` падает YN0028 → `yarn install` → перепроверка
// gate — CI-эквивалент ЛОКАЛЬНО до пуша (gotcha 10/11):
// tsc --noEmit + vitest (vitest.ci.config.ts) прямыми вызовами
// node_modules (обходит Windows yarn-workspace-bin-болезнь)
// commit — один чистый коммит (amend при --amend)
// push — только если gate зелёный (без --push — стоп перед пушем)
//
// Windows-заметка: `yarn workspace run` не добавляет root .bin в PATH
// (command not found: tsc) — гейт вызывает node node_modules/... напрямую.
//
// Использование:
// node scripts/sync-sched-publish.mjs # полный цикл до пуша (стоп, показать)
// node scripts/sync-sched-publish.mjs --push # + push origin main
// node scripts/sync-sched-publish.mjs --amend # amend последнего коммита (после фикса dev)
// node scripts/sync-sched-publish.mjs --only=lock # только фаза
// node scripts/sync-sched-publish.mjs --no-copy # пропустить фазы copy/bleach/scrub
//
// Возврат: 0 = зелёно, 1 = что-то упало (причины в stdout, мутаций нет после точки отказа).
import { execSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
const HOME = os.homedir().replace(/\\/g, "/");
const DEV = process.env.SCHED_DEV || `${HOME}/projects/sched`;
const PUB = process.env.SCHED_PUB || `${HOME}/projects/sched-upstream`;
const args = process.argv.slice(2);
const PUSH = args.includes("--push");
const AMEND = args.includes("--amend");
const ONLY = args.includes("--only=") ? args.find((a) => a.startsWith("--only=")).slice(7) : null;
const NO_COPY = args.includes("--no-copy");
// ── helpers ────────────────────────────────────────────────────────────────
const sh = (cmd, opts = {}) =>
execSync(cmd, { stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", ...opts });
const shSilent = (cmd) => {
try { return sh(cmd).trim(); } catch { return null; }
};
const log = (s) => console.log(s);
const fail = (s) => { console.error(`\n${s}`); process.exitCode = 1; };
function copyTree(srcFile, dstFile) {
if (!fs.existsSync(srcFile)) return; // удалён в dev → удалится в pub через git add -A
fs.mkdirSync(path.dirname(dstFile), { recursive: true });
fs.copyFileSync(srcFile, dstFile);
}
// ── фазы ───────────────────────────────────────────────────────────────────
function collect() {
const out = shSilent(`git -C "${DEV}" ls-files`);
if (out === null) { fail("dev не git-репо"); return null; }
const EXCL = /^\.wiki\/|^\.tasks\/|^\.agents\/|^AGENTS\.md$|^CLAUDE\.md$|^branding\/pora-|^pngtree-|^327274997_|^cat-clock-|^MIGRATION\.md$/;
return out.split("\n").filter((f) => f && !EXCL.test(f));
}
function copy(files) {
for (const f of files) copyTree(`${DEV}/${f}`, `${PUB}/${f}`);
log(`copy: ${files.length} файлов`);
}
// выбеливание — точечные замены, идемпотентно (эталон: верифицированный b0afd14)
function bleach() {
const sub = (rel, pairs) => {
const p = `${PUB}/${rel}`;
if (!fs.existsSync(p)) return;
let s = fs.readFileSync(p, "utf8");
let dirty = false;
for (const [old, nw] of pairs) if (s.includes(old)) { s = s.replaceAll(old, nw); dirty = true; }
if (dirty) fs.writeFileSync(p, s);
};
// .yarnrc.yml — канон целиком (3 строки, фиксированный)
fs.writeFileSync(`${PUB}/.yarnrc.yml`, `nodeLinker: node-modules\nenableTelemetry: false\nnpmRegistryServer: "https://registry.npmjs.org"\n`);
sub("docker-compose.dev.yml", [
["# npm-based image: pull @schedjs/daemon from the private verdaccio.",
"# npm-based image: pull @schedjs/daemon from the public npm registry."],
["NPM_REGISTRY: ${NPM_REGISTRY:-https://verdaccio.kzntsv.site}",
"NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org}"],
["NPM_TOKEN: ${VERDACCIO_TOKEN:-}", "NPM_TOKEN: ${NPM_TOKEN:-}"],
]);
sub("apps/daemon/Dockerfile", [
["# Build (public registry):\n# docker build -t schedd:0.10.2 apps/daemon\n# Build (private verdaccio — our internal registry):\n# docker build --secret id=verdaccio_token,env=VERDACCIO_TOKEN \\\n# --build-arg NPM_REGISTRY=https://verdaccio.kzntsv.site \\\n# -t registry.kzntsv.site/sched-daemon:0.10.2 apps/daemon\n#",
"# Build:\n# docker build -t schedd:0.12.1 apps/daemon\n#\n# Private-registry mirror (optional, internal use): pass the registry URL as\n# NPM_REGISTRY + a BuildKit secret to install from a mirror instead of npmjs.\n#"],
["(verdaccio serves none)", "(a mirror serves none)"],
["# VERDACCIO_TOKEN comes from the BuildKit secret (mount type=secret).",
"# NPM_TOKEN comes from the BuildKit secret (mount type=secret)."],
["id=verdaccio_token", "id=npm_token"],
["/run/secrets/verdaccio_token", "/run/secrets/npm_token"],
]);
sub("docs/nuxt.config.ts", [
["?? 'https://git.kzntsv.site/victor/sched'", "?? 'https://github.com/schedjs/schedjs'"],
["?? 'https://verdaccio.kzntsv.site'", "?? 'https://registry.npmjs.org'"],
["?? 'registry.kzntsv.site'", "?? 'ghcr.io/schedjs'"],
]);
sub("docs/modules/sched-links.ts", [
["?? 'https://git.kzntsv.site/victor/sched'", "?? 'https://github.com/schedjs/schedjs'"],
["?? 'https://verdaccio.kzntsv.site'", "?? 'https://registry.npmjs.org'"],
["?? 'registry.kzntsv.site'", "?? 'ghcr.io/schedjs'"],
["VERDACCIO_TOKEN", "NPM_TOKEN"],
]);
sub("CHANGELOG.md", [
["на verdaccio", "на npm"], ["→ verdaccio", "→ npm"],
["registry.kzntsv.site/sched-daemon", "ghcr.io/schedjs/sched-daemon"],
]);
for (const p of ["scripts/publish-check.mjs", "scripts/publish-check.test.mjs"]) {
sub(p, [["registry.kzntsv.site/sched-daemon", "ghcr.io/schedjs/sched-daemon"]]);
}
for (const f of ["examples/admin-client-python/generate.ps1", "examples/admin-client-ts/generate.ps1"]) {
sub(f, [[" (verdaccio subpath export);", ";"]]);
}
for (const f of [
"examples/admin-client-python/README.md",
"examples/admin-client-ts/README.md",
"examples/admin-client-python/tests/test_admin_client.py",
]) {
sub(f, [["verdaccio", "npm"]]);
}
log("bleach: выбелено");
}
function scrubDist() {
const devDist = `${DEV}/docs/dist`;
const pubDist = `${PUB}/docs/dist`;
if (!fs.existsSync(devDist)) { log("scrub: нет dev docs/dist — пропуск"); return; }
fs.rmSync(pubDist, { recursive: true, force: true });
fs.cpSync(devDist, pubDist, { recursive: true });
// скраб остатков kzntsv (прямые + \u002F + \\u002F в _payload.json)
const pairs = [
["git.kzntsv.site/victor/sched", "github.com/schedjs/schedjs"],
["verdaccio.kzntsv.site/:_authToken=${VERDACCIO_TOKEN}", "registry.npmjs.org/:_authToken=${NPM_TOKEN}"],
["verdaccio.kzntsv.site", "registry.npmjs.org"],
["registry.kzntsv.site/sched-daemon", "ghcr.io/schedjs/sched-daemon"],
["registry.kzntsv.site", "ghcr.io/schedjs"],
["VERDACCIO_TOKEN", "NPM_TOKEN"],
];
const esc1 = "\\u002F", esc2 = "\\\\u002F"; // \u002F и \\u002F (JSON-двойной)
const escPairs = [
["git.kzntsv.site" + esc2 + "victor" + esc2 + "sched", "github.com" + esc2 + "schedjs" + esc2 + "schedjs"],
["git.kzntsv.site" + esc1 + "victor" + esc1 + "sched", "github.com" + esc1 + "schedjs" + esc1 + "schedjs"],
["registry.kzntsv.site" + esc2 + "sched-daemon", "ghcr.io/schedjs" + esc2 + "sched-daemon"],
["registry.kzntsv.site" + esc1 + "sched-daemon", "ghcr.io/schedjs" + esc1 + "sched-daemon"],
["verdaccio.kzntsv.site" + esc2 + ":_authToken=${VERDACCIO_TOKEN}", "registry.npmjs.org" + esc2 + ":_authToken=${NPM_TOKEN}"],
["verdaccio.kzntsv.site" + esc1 + ":_authToken=${VERDACCIO_TOKEN}", "registry.npmjs.org" + esc1 + ":_authToken=${NPM_TOKEN}"],
["verdaccio.kzntsv.site" + esc2, "registry.npmjs.org" + esc2],
["verdaccio.kzntsv.site" + esc1, "registry.npmjs.org" + esc1],
["registry.kzntsv.site" + esc2, "ghcr.io/schedjs" + esc2],
["registry.kzntsv.site" + esc1, "ghcr.io/schedjs" + esc1],
];
const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const p = `${dir}/${e.name}`;
return e.isDirectory() ? walk(p) : [p];
});
let changed = 0;
for (const f of walk(pubDist)) {
if (f.endsWith(".svg")) continue;
let data;
try { data = fs.readFileSync(f, "utf8"); } catch { continue; }
let orig = data;
for (const [old, nw] of pairs) data = data.split(old).join(nw);
for (const [old, nw] of escPairs) data = data.split(old).join(nw);
if (data !== orig) { fs.writeFileSync(f, data); changed++; }
}
log(`scrub: dist скопирован, изменено ${changed} файлов`);
// logo.svg НЕ коммитить (gotcha 4)
if (fs.existsSync(`${pubDist}/logo.svg`)) fs.rmSync(`${pubDist}/logo.svg`);
}
function lock() {
// сначала immutable; если падает — регенерация и перепроверка
const imm = shSilent(`cd "${PUB}" && yarn install --immutable`);
if (imm === null || imm.includes("YN0028") || /Failed with errors/.test(imm)) {
log("lock: immutable упал — регенерирую против npmjs");
const regen = shSilent(`cd "${PUB}" && yarn install`);
if (regen === null || /Failed with errors/.test(regen)) { fail("lock: yarn install упал"); return false; }
const imm2 = shSilent(`cd "${PUB}" && yarn install --immutable`);
if (imm2 === null || /Failed with errors/.test(imm2) || imm2.includes("YN0028")) {
fail("lock: immutable после регенерации всё ещё красный"); return false;
}
}
log("lock: yarn.lock валиден против npmjs");
return true;
}
function gate() {
// CI-эквивалент прямыми вызовами node_modules (Windows: yarn workspace run не видит .bin)
// gotcha 5: build до tsc (cross-package types через dist) — собрать dist всех пакетов
// топологический порядок (как yarn workspaces -pt): пакет раньше своих @schedjs-потребителей
const buildCfgs = [];
for (const sub of ["packages", "apps"]) {
for (const dir of fs.readdirSync(`${PUB}/${sub}`)) {
const cfg = `${PUB}/${sub}/${dir}/tsconfig.build.json`;
if (fs.existsSync(cfg)) buildCfgs.push(`${sub}/${dir}`);
}
}
const order = [];
const remaining = new Set(buildCfgs);
const depsOf = (p) => {
const pj = JSON.parse(fs.readFileSync(`${PUB}/${p}/package.json`, "utf8"));
const deps = { ...(pj.dependencies || {}), ...(pj.devDependencies || {}), ...(pj.peerDependencies || {}) };
return Object.keys(deps).filter((d) => d.startsWith("@schedjs/"));
};
const localName = (p) => {
const pj = JSON.parse(fs.readFileSync(`${PUB}/${p}/package.json`, "utf8"));
return pj.name;
};
const localNames = new Map(buildCfgs.map((p) => [localName(p), p]));
while (remaining.size) {
let progress = false;
for (const p of [...remaining]) {
const unmet = depsOf(p).filter((d) => localNames.has(d) && !order.includes(localNames.get(d)));
if (unmet.length === 0) { order.push(p); remaining.delete(p); progress = true; }
}
if (!progress) { fail("gate: цикл зависимостей build-пакетов"); return false; }
}
for (const pkg of order) {
const clean = `${PUB}/${pkg}/dist`;
if (fs.existsSync(clean)) fs.rmSync(clean, { recursive: true, force: true });
const b = shSilent(`cd "${PUB}/${pkg}" && node ../../node_modules/typescript/bin/tsc -p tsconfig.build.json`);
if (b === null || /error TS/.test(b)) {
fail(`gate: build ${pkg} упал`);
return false;
}
// полный build-скрипт пакета может включать bundle (esbuild) — повторить
const pj = JSON.parse(fs.readFileSync(`${PUB}/${pkg}/package.json`, "utf8"));
if ((pj.scripts?.build || "").includes("bundle.mjs")) {
const bun = shSilent(`cd "${PUB}/${pkg}" && node scripts/bundle.mjs`);
if (bun === null) { fail(`gate: bundle ${pkg} упал`); return false; }
}
}
log(`gate: build ${order.length} пакетов (dist, топо-порядок)`);
const tsc = shSilent(`cd "${PUB}" && node node_modules/typescript/bin/tsc --noEmit`);
if (tsc === null || /error TS/.test(tsc)) {
const errs = (tsc || "").split("\n").filter((l) => l.includes("error TS")).slice(0, 10);
fail(`gate: tsc — ${errs.length} ошибок${errs.length ? "\n" + errs.join("\n") : ""}`);
return false;
}
log("gate: tsc зелёный");
const vit = shSilent(`cd "${PUB}" && node node_modules/vitest/vitest.mjs run --config vitest.ci.config.ts`);
if (vit === null || /failed/.test(vit)) {
fail("gate: vitest упал (смотри вывод)");
return false;
}
const passLine = (vit.match(/(\d+) passed/) || [])[1] || "?";
log(`gate: vitest зелёный (${passLine} passed)`);
return true;
}
function grepCheck() {
const hits = shSilent(`cd "${PUB}" && grep -rnE 'git\\.kzntsv|verdaccio|vds\\.kzntsv|registry\\.kzntsv|VERDACCIO' --exclude-dir=node_modules --exclude-dir=.git .`);
if (hits && hits.length > 0) {
fail(`grep: остались ${hits.split("\n").length} вхождений kzntsv/verdaccio`);
return false;
}
log("grep: 0 вхождений kzntsv/verdaccio");
return true;
}
function commit() {
const msg = `chore: sync curated copy from dev (${shSilent(`cd "${DEV}" && git log -1 --format=%h`) || "?"})`;
sh(`cd "${PUB}" && git add -A`);
const staged = shSilent(`cd "${PUB}" && git status -s`);
if (!staged || staged.trim() === "") {
log("commit: нет изменений — волна уже синхронизирована");
return true;
}
if (AMEND) {
shSilent(`cd "${PUB}" && git commit --amend --no-edit`);
log("commit: amended");
} else {
sh(`cd "${PUB}" && git commit -m "${msg}"`);
log(`commit: ${shSilent(`cd "${PUB}" && git log -1 --format=%s`)}`);
}
return true;
}
// ── main ────────────────────────────────────────────────────────────────────
if (!fs.existsSync(`${PUB}/.git`)) { fail("pub не git-репо"); process.exit(1); }
if (!fs.existsSync(`${DEV}/.git`)) { fail("dev не git-репо"); process.exit(1); }
const files = collect();
if (!files) process.exit(1);
if (ONLY && ONLY !== "lock") { fail("--only поддерживает: lock"); process.exit(1); }
let ok = true;
if (!ONLY) {
if (!NO_COPY) { copy(files); bleach(); scrubDist(); }
ok = grepCheck() && ok;
}
if (ONLY === "lock") {
ok = lock() && ok;
} else if (!NO_COPY) {
ok = lock() && ok;
}
if (ok && !ONLY && !NO_COPY) {
ok = gate() && ok;
if (ok) ok = commit() && ok;
}
if (!ok) {
log("\n✗ цикл прерван — мутаций после точки отказа нет. Фикс в dev → повтор.");
process.exit(1);
}
if (!PUSH) {
log(`\n✓ готово до пуша: ${shSilent(`cd "${PUB}" && git log --oneline -1`)}`);
log(" запусти с --push чтобы отправить origin main");
process.exit(0);
}
const p = shSilent(`cd "${PUB}" && git push origin main`);
if (p === null || /rejected/.test(p)) { fail("push отклонён — pull --ff-only и повтори"); process.exit(1); }
log("✓ pushed origin main");