#!/usr/bin/env bash # Снимок сервисного репозитория АРМ КА для переноса в контур банка (dev/contour-transfer-guide.md). # # Что делает: берёт дерево коммита (`git archive`, без истории), вырезает то, чего в контуре нет или что ему не нужно # (AI-инструментарий, планы, Dockerfile для локальной сборки), переводит `make venv` с uv на `python -m venv` + pip через # Nexus (как `setup/setup.sh` шаблона), убирает цель docker-build, заменяет ссылки на внешний Gitea, пишет SNAPSHOT.md с # провенансом и проверяет результат. Репозиторий-источник не меняется. # # Запуск из workspace: docs/scripts/prepare-contour-snapshot.sh <каталог репозитория> <каталог вывода> [ref] # ref по умолчанию — origin/main (снимок всегда с сервера, не с рабочего дерева). # Пример: docs/scripts/prepare-contour-snapshot.sh tabular-parser /tmp/contour # Результат: <каталог вывода>/<имя репозитория>/ — готовое дерево для rsync в клон репозитория контура. # # Флаг --web-as-is остался от синхронизации с arm-web (до 21.10.2026) и игнорируется: фронт ведётся в arm-core/web. # Для каждого сервиса, где в $REF есть api/arm_statements, проверяется свежесть копии пакета (sync-statements.sh --check # по дереву $REF; Р-42): отставание — предупреждение и абзац в SNAPSHOT.md, прямая правка копии — стоп (код 1), нет # arm-statements или Python — предупреждение. Нет api/arm_statements — проверка пропускается. $SYNC_STATEMENTS_ARGS — # дополнительные аргументы sync-statements.sh (для теста: --source … --docs …). # $SNAPSHOT_SKIP_WEB_BUILD=1 — не проверять сборку web/ (для тестов без сети); по умолчанию проверка включена. # # Встроенный фронт arm-core в контуре только собирается (npm install через Nexus + rsbuild build), а зеркало npm банка не # содержит свежих версий инструментов разработки. Поэтому в снимке web/package.json урезается по РАЗРЕШЁННОМУ списку # (решение владельца 16.10.2026); всё остальное — eslint, prettier, biome, typescript, vitest, @types/* — разработка # и проверки идут снаружи. Список — единственное место, которое правят, если сборке понадобится ещё пакет. WEB_DEV_ALLOW=(@rsbuild/core @rsbuild/plugin-react tailwindcss daisyui) # devDependencies, нужные сборке WEB_OPTIONAL_KEEP_PREFIX="raisa-" # optionalDependencies: платформенные пакеты WEB_SCRIPTS_KEEP=(build start preview) # scripts, работающие без инструментов разработки set -euo pipefail usage() { echo "usage: $0 [ref]" >&2; exit 2; } if [ "${1:-}" = "--web-as-is" ]; then echo "предупреждение: --web-as-is больше не нужен (фронт ведётся в arm-core/web) — флаг игнорируется" >&2; shift fi [ $# -ge 2 ] || usage REPO=$(cd "$1" && pwd) mkdir -p "$2" OUT=$(cd "$2" && pwd) REF=${3:-origin/main} NAME=$(basename "$REPO") DEST="$OUT/$NAME" case "$NAME" in arm-core|tabular-parser|scan-gateway) ;; *) echo "ошибка: $NAME — не сервисный репозиторий (ожидаются arm-core, tabular-parser, scan-gateway); docs и fixtures в контур не переносятся" >&2; exit 2 ;; esac if [ -e "$DEST" ]; then echo "ошибка: $DEST уже существует — удалите или укажите другой каталог вывода" >&2 exit 2 fi # --- 0a. Копия пакета arm_statements в api/arm_statements не должна отставать от arm-statements --------------------------- # Проверяется дерево $REF хоста (git archive api → временный каталог), не рабочее. Нет api/arm_statements в $REF — пакет в # хост ещё не подключён, проверка молча пропускается. Коды sync-statements.sh --check: 0 — тихо; 10 — предупреждение в # stderr и абзац в SNAPSHOT.md; 20 — прямая правка/чужая версия копии, снимок не делается; 2 (нет arm-statements или # интерпретатора) — предупреждение, снимок делается. git -C "$REPO" fetch -q origin STATEMENTS_LAG="" if git -C "$REPO" rev-parse -q --verify "$REF:api/arm_statements" >/dev/null 2>&1; then SYNC_STATEMENTS="$(cd "$(dirname "$0")" && pwd)/sync-statements.sh" STM_TMP=$(mktemp -d) trap 'rm -rf "$STM_TMP"' EXIT git -C "$REPO" archive "$REF" api | tar -x -C "$STM_TMP" rc=0 # shellcheck disable=SC2086 stm_out=$("$SYNC_STATEMENTS" --check --host "$NAME" --host-dir "$STM_TMP" ${SYNC_STATEMENTS_ARGS:-} 2>&1) || rc=$? rm -rf "$STM_TMP" case "$rc" in 0) ;; 10) echo "предупреждение: копия arm_statements в $NAME отстаёт от arm-statements или требует внимания:" >&2 echo "$stm_out" >&2 STATEMENTS_LAG=$(printf '%s\n' "$stm_out" | grep '^statements-check' | tail -n 1) [ -n "$STATEMENTS_LAG" ] || STATEMENTS_LAG="sync-statements.sh --check вернул 10" ;; 20) echo "$stm_out" >&2 echo "ошибка: копия arm_statements в $NAME расходится с arm-statements (прямые правки или чужая версия) — снимок не делается, разберитесь вручную" >&2 exit 1 ;; 2) echo "предупреждение: копия arm_statements в $NAME не проверена (sync-statements.sh --check: нет arm-statements рядом или подходящего Python):" >&2 echo "$stm_out" >&2 ;; *) echo "$stm_out" >&2 echo "ошибка: sync-statements.sh --check завершился с кодом $rc" >&2; exit 1 ;; esac fi HASH=$(git -C "$REPO" rev-parse "$REF") DATE=$(git -C "$REPO" show -s --format=%cs "$REF") SUBJECT=$(git -C "$REPO" show -s --format=%s "$REF") mkdir -p "$DEST" git -C "$REPO" archive "$REF" | tar -x -C "$DEST" # --- 1. Вырезать то, чего в контуре нет ------------------------------------------------------------------------ # .claude/, CLAUDE.md, openspec/config.yaml — инструкции и контекст AI-агента (config.yaml: блоки context/rules с путями # ../docs и ../fixtures; openspec CLI в контуре нет, specs/ и design.md читаются без него); docs/superpowers/ — планы и # ledger (в них локальные пути); Dockerfile/.dockerignore — локальная сборка образа, платформа собирает образ своим CI. # openspec/specs/ и openspec/changes/archive/ остаются: это спецификация проекта и решения (design.md). # Артефакты агента вырезаются на любой глубине (вложенный проект вроде arm-core/web/ может нести свои .claude/, CLAUDE.md, # openspec/config.yaml, docs/superpowers/ и .codegraph/). Dockerfile/.dockerignore — только в корне (образ сервиса). rm -rf "$DEST/Dockerfile" "$DEST/.dockerignore" find "$DEST" \( \( -type d \( -name .claude -o -name .codegraph -o -path '*/docs/superpowers' \) \) \ -o \( -type f \( -name CLAUDE.md -o -path '*/openspec/config.yaml' \) \) \) -prune -exec rm -rf {} + find "$DEST" -type d -name docs -empty -delete # --- 2. Makefile: venv через python -m venv + pip с Nexus, без uv; без docker-build -------------------------------- python3 -I - "$DEST/Makefile" <<'PY' import re import sys from pathlib import Path path = Path(sys.argv[1]) lines = path.read_text(encoding="utf-8").splitlines() out: list[str] = [] i = 0 venv_patched = False while i < len(lines): line = lines[i] # рецепт venv: все строки с uv заменяются одним рецептом на pip if line.startswith("venv:"): comment_start = len(out) while comment_start > 0 and out[comment_start - 1].startswith("#"): comment_start -= 1 del out[comment_start:] out.append("# Контур банка: Python 3.11 платформы, pip через Nexus (setup/pip.conf), как в setup/setup.sh; dev-зависимости") out.append("# тянут requirements.txt. PYTHON переопределяется: make venv PYTHON=/usr/bin/python3.11") out.append("PYTHON ?= python3.11") out.append("venv:") out.append("\tcd api && $(PYTHON) -m venv .venv") out.append("\tcp setup/pip.conf api/.venv/pip.conf") out.append("\tcd api && .venv/bin/pip install -r requirements-dev.txt") i += 1 while i < len(lines) and lines[i].startswith("\t"): i += 1 venv_patched = True continue # цель docker-build и комментарий над ней удаляются if line.startswith("docker-build:"): comment_start = len(out) while comment_start > 0 and out[comment_start - 1].startswith("#"): comment_start -= 1 del out[comment_start:] i += 1 while i < len(lines) and lines[i].startswith("\t"): i += 1 if i < len(lines) and lines[i] == "" and out and out[-1] == "": i += 1 continue # web-check в контуре: только npm install и npm run build (biome/tsc убраны вместе с пакетами) if line.startswith("web-check:"): out.append(line) i += 1 while i < len(lines) and lines[i].startswith("\t"): if "npx " not in lines[i]: out.append(lines[i]) i += 1 continue if line.startswith("# Фронт:"): line = line.replace("biome, типы, ", "") if line.startswith("check-full:"): line = re.sub(r"\s+docker-build\b", "", line) if line.startswith("\t$(MAKE) docker-build"): i += 1 continue if line.startswith(".PHONY:"): line = re.sub(r"\s+docker-build\b", "", line) if line.startswith('\t@echo "docker-build'): i += 1 continue line = line.replace(" + docker-build", "") out.append(line) i += 1 if not venv_patched: sys.exit("Makefile: цель venv не найдена — скрипт устарел относительно репозитория") path.write_text("\n".join(out) + "\n", encoding="utf-8") PY # --- 2a. arm-core: web/package.json только с зависимостями сборки --------------------------------------------------------- WEB_REMOVED="" if [ -f "$DEST/web/package.json" ]; then WEB_REMOVED=$(python3 -I - "$DEST/web/package.json" "$WEB_OPTIONAL_KEEP_PREFIX" "${WEB_DEV_ALLOW[*]}" "${WEB_SCRIPTS_KEEP[*]}" <<'PY' import json import sys from pathlib import Path path = Path(sys.argv[1]) optional_prefix = sys.argv[2] dev_allow = sys.argv[3].split() scripts_keep = sys.argv[4].split() pkg = json.loads(path.read_text(encoding="utf-8")) missing = [d for d in dev_allow if d not in pkg.get("devDependencies", {})] if missing: sys.exit(f"web/package.json: в devDependencies нет {', '.join(missing)} из разрешённого списка — скрипт устарел относительно репозитория") dev = pkg.get("devDependencies", {}) opt = pkg.get("optionalDependencies", {}) scripts = pkg.get("scripts", {}) removed_dev = [n for n in dev if n not in dev_allow] removed_opt = [n for n in opt if not n.startswith(optional_prefix)] removed_scripts = [n for n in scripts if n not in scripts_keep] pkg["devDependencies"] = {n: v for n, v in dev.items() if n in dev_allow} if "optionalDependencies" in pkg: pkg["optionalDependencies"] = {n: v for n, v in opt.items() if n.startswith(optional_prefix)} pkg["scripts"] = {n: v for n, v in scripts.items() if n in scripts_keep} path.write_text(json.dumps(pkg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") print("devDependencies: " + (", ".join(removed_dev) or "—")) print("optionalDependencies: " + (", ".join(removed_opt) or "—")) print("scripts: " + (", ".join(removed_scripts) or "—")) PY ) fi # --- 3. Ссылки на внешний Gitea → имя репозитория и номер --------------------------------------------------------- # В openspec-архивах и README встречаются ссылки вида https://git.aepif.ru/RSHB//issues/5 и pulls/7. while IFS= read -r file; do [ -n "$file" ] || continue python3 -I - "$file" <<'PY' import re import sys from pathlib import Path path = Path(sys.argv[1]) text = path.read_text(encoding="utf-8") text = re.sub(r"https?://git\.aepif\.ru/(RSHB/[\w.-]+)/(issues|pulls)/(\d+)", r"\1#\3", text) text = re.sub(r"https?://git\.aepif\.ru/?", "внешний Gitea проекта", text) path.write_text(text, encoding="utf-8") PY done < <(grep -rIl "git.aepif.ru" "$DEST" 2>/dev/null || true) # --- 4. Провенанс --------------------------------------------------------------------------------------------------- cat > "$DEST/SNAPSHOT.md" <> "$DEST/SNAPSHOT.md" fi if [ -n "$STATEMENTS_LAG" ]; then printf '\nКопия пакета arm_statements (`api/arm_statements/`) на момент снимка требует внимания: %s\n' "$STATEMENTS_LAG" >> "$DEST/SNAPSHOT.md" fi # --- 5. Проверки ---------------------------------------------------------------------------------------------------- fail=0 for p in Dockerfile .dockerignore; do if [ -e "$DEST/$p" ]; then echo "ошибка: в снимке остался $p" >&2; fail=1; fi done left=$(cd "$DEST" && find . \( -name .claude -o -name .codegraph -o -name CLAUDE.md -o -path '*/openspec/config.yaml' \ -o -path '*/docs/superpowers' \) -print) if [ -n "$left" ]; then echo "ошибка: в снимке остались артефакты агента:" >&2; echo "$left" >&2; fail=1; fi if grep -rIn "/Users/\|/home/[a-z]" "$DEST" >/dev/null 2>&1; then echo "ошибка: в снимке есть локальные пути:" >&2; grep -rIn "/Users/\|/home/[a-z]" "$DEST" >&2; fail=1 fi if grep -rIn "git.aepif.ru\|uv venv\|uv pip" "$DEST" >/dev/null 2>&1; then echo "ошибка: остались ссылки на внешний Gitea или uv:" >&2; grep -rIn "git.aepif.ru\|uv venv\|uv pip" "$DEST" >&2; fail=1 fi for p in raisa_config.yml setup/setup.sh setup/pip.conf api/run_server.py api/requirements.txt api/.env.test api/contracts; do if [ ! -e "$DEST/$p" ]; then echo "ошибка: платформенный файл $p отсутствует" >&2; fail=1; fi done # Сборка урезанного web/ собирается в ВРЕМЕННОЙ копии: снимок остаётся без node_modules/dist/package-lock.json, а упавшая # проверка не оставляет полуготовых файлов. Нужен npm и доступ к реестру (в контуре — Nexus, снаружи — публичный npm; # raisa-* опциональны и снаружи пропускаются). Падение сборки почти всегда значит: пакет, нужный сборке, не в WEB_DEV_ALLOW. if [ -f "$DEST/web/package.json" ] && [ "${SNAPSHOT_SKIP_WEB_BUILD:-0}" != 1 ]; then echo "проверка сборки урезанного web/ (npm install + npm run build)…" >&2 BUILD_TMP=$(mktemp -d) cp -R "$DEST/web" "$BUILD_TMP/web" if ! { (cd "$BUILD_TMP/web" && npm install --no-audit --no-fund && npm run build) >"$BUILD_TMP/build.log" 2>&1; }; then tail -n 30 "$BUILD_TMP/build.log" >&2 rm -rf "$BUILD_TMP" "$DEST" echo "ошибка: сборка урезанного web/ не прошла — пакет, нужный сборке, не в разрешённом списке (WEB_DEV_ALLOW в $(basename "$0"))." >&2 echo "снимок не выдан." >&2 exit 1 fi rm -rf "$BUILD_TMP" fi [ "$fail" = 0 ] || exit 1 echo "снимок готов: $DEST" echo "источник: $NAME@${HASH:0:7} ($DATE); файлов: $(find "$DEST" -type f | wc -l | tr -d ' ')"