Files
admin/scripts/sync-sched-publish.mjs
vitya cbffb4a0ca 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 изменений.
2026-08-31 08:55:38 +03:00

335 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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");