diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..7d51d185 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Merge-драйвер для дневника: запись только добавляется, поэтому конфликт +# "оба правы" — склеиваем обе стороны. Без этого каждый PR конфликтует в +# AGENT_DIARY.md и требует ещё один merge + ещё один прогон CI +# (2026-11-15: четыре PR подряд, каждый — заново). +AGENT_DIARY.md merge=union diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d9c265b..d13a38ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,33 @@ on: # чтобы не задваивать прогоны для одной и той же ревизии. branches: [main, 'feat/**', 'exp/**', 'fix/**', 'chore/**'] +concurrency: + # Отменяем устаревший прогон того же PR: замер 2026-11-15 — 28 из 46 + # PR-прогонов были перезапусками одного дерева. main не отменяем: его + # история прогонов — доказательство, а не черновик. + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read jobs: + changes: + # Дешёвый фильтр: тяжёлые job'ы нужны только когда менялся код. + # Docs-only PR экономит ~4.7 из 12.6 runner-минут на прогон (замер 2026-11-15). + runs-on: ubuntu-latest + outputs: + code: ${{ steps.diff.outputs.code }} + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + with: + fetch-depth: 0 + persist-credentials: false + - id: diff + env: + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + run: echo "code=$(python scripts/ci_changed_code.py "$BASE_SHA")" >> "$GITHUB_OUTPUT" + test: strategy: fail-fast: false @@ -121,6 +144,8 @@ jobs: timeout-minutes: 2 clean-state: + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' runs-on: ubuntu-latest steps: # fetch-depth: 0 — см. комментарий в job test (revision_gate требует ancestry). @@ -174,6 +199,8 @@ jobs: timeout-minutes: 5 manifest-parity: + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' # B-1 (ADR-0005 scaling): наши экстракторы манифестов vs osv-scanner на корпусе # e-s1-polygon (Вариант В спеки 07). Выхлоп обязан совпадать (расхождение = дыра # в нашем парсере). Пин: osv-scanner v2.5.1 (release 2026-08-17); бамп по §5.19. @@ -200,6 +227,8 @@ jobs: timeout-minutes: 5 transport-equiv-e2e: + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' # E-07 (DoD Фазы 3): эквивалентность stdio ↔ Streamable HTTP на РЕАЛЬНОМ движке. # Локально при живом основном MCP нестабилен (PID-lock embedder) — автор # харнесса: только чистый раннер (CI Ubuntu). @@ -223,6 +252,8 @@ jobs: timeout-minutes: 10 docker-build: + needs: changes + if: github.event_name == 'push' || needs.changes.outputs.code == 'true' # DoD Фазы 3 (deploy/docker): образ из python-only remote-движка собирается # (локально Docker недоступен — CI-билд является верификацией). runs-on: ubuntu-latest diff --git a/AGENT_DIARY.md b/AGENT_DIARY.md index 59121f72..27a0f8b5 100644 --- a/AGENT_DIARY.md +++ b/AGENT_DIARY.md @@ -1980,3 +1980,15 @@ BREACH: MSC-P-19 | date: 2026-10-03 | rule: §12 | guard_at_time: no | detected_ **Fix (тесты, не код):** чтение через `_file_rows(indexer, columns)` — берёт слой, который реально использует индексатор, и агрегирует чанки SQLite по `file_path`; добавлен `test_store_is_the_default_one`, который пиннит дефолт (`sqlite`), чтобы дрейф был виден. `slow`-маркер снят: файл занимает 4.2 s и обязан быть в гейте — иначе эта поломка снова спрячется. **Проверка:** до фикса 5 failed / 2 passed; после — 8 passed. Мутация: `verify_index_freshness()` принудительно `return 0` → 4 failed / 4 passed (код восстановлен, тесты краснеют как надо). `ruff check src/ tests/` — чисто. **Урок:** «slow» — это не «не важно», а «не проверяется». Если тест читает конкретный бэкенд, он обязан падать громко при смене дефолта, а не жить в исключённой зоне. + +## [2026-11-15] Экономия CI: отмена устаревших прогонов, фильтр «docs-only», union-merge дневника + +**Симптом:** 120 последних прогонов = 64 CI + 56 CodeQL; из 46 PR-прогонов **28 — перезапуски одного и того же дерева** (нет `cancel-in-progress`). Один прогон CI = **12.6 runner-минут** (7 job'ов): `test` windows 5.0, ubuntu 2.7, `clean-state` 1.7, `manifest-parity` 1.4, `docker-build` 0.8, `transport-equiv-e2e` 0.8, `secret-scan` 0.2. Плюс каждый второй PR падал в конфликт `AGENT_DIARY.md` и требовал ещё один merge → ещё один прогон. + +**Fix:** `concurrency: group: ci-${workflow}-${ref}, cancel-in-progress: pull_request` (main не отменяем — его история прогонов доказательство); job `changes` с `scripts/ci_changed_code.py` (Python, чтобы тестировалось и работало на обоих OS) отдаёт `code=true|false`, и `clean-state`/`manifest-parity`/`transport-equiv-e2e`/`docker-build` получили `needs: changes` + `if: push || code == 'true'`; `.gitattributes` → `AGENT_DIARY.md merge=union` (дневник только дописывается, «оба правы» — верное разрешение). + +**Проверка:** `tests/test_ci_changed_code.py` — 9 тестов (docs/реестры/эксперименты/картинки → не код; `src/`/`install.py`/`pyproject.toml` → код; неизвестная база → fail-open `true`; `HEAD..HEAD` → `false`; структура workflow: concurrency привязан к PR, 4 тяжёлых job'а ждут фильтр). YAML парсится: 7 job'ов, `changes` первым. + +**Ожидаемый эффект (оценка, не замер):** −28 прогонов из 46 в окне наблюдения; −4.7 из 12.6 минут на docs-only PR; минус повторные merge-прогоны из-за дневника. Деньги не экономятся: репозиторий публичный, стандартные раннеры бесплатны — экономится очередь и время до вердикта. + +**Урок:** «тяжёлый CI» чаще не про мощность, а про отсутствие отмены и про то, что реестр-файл редактируют все сразу. Правило `merge=union` для append-only файла дешевле любых процессов. diff --git a/scripts/ci_changed_code.py b/scripts/ci_changed_code.py new file mode 100644 index 00000000..2b359177 --- /dev/null +++ b/scripts/ci_changed_code.py @@ -0,0 +1,76 @@ +"""Ответ на один вопрос: менялся ли в этом изменении код (а не только текст). + +Нужен `changes`-job'у: тяжёлые job'ы (clean-state, docker-build, +transport-equiv-e2e, manifest-parity) стоят ~4.7 из 12.6 runner-минут на прогон +(замер 2026-11-15), а docs-only PR их не требует. Логика в Python, а не в +inline-shell, чтобы её можно было проверить тестами и чтобы она одинаково +работала на ubuntu и windows. + +Печатает `true` или `false`. Пустая/неизвестная база — `true` (fail-open: +лучше прогнать лишнее, чем пропустить проверку). + +Usage: + python scripts/ci_changed_code.py [--root .] +""" + +from __future__ import annotations + +import argparse +import re +import subprocess +import sys +from pathlib import Path + +# Текст, который кодом не является: документация, реестры, артефакты прогонов. +TEXT_PATHS = re.compile( + r"""^( + docs/ # канонические страницы и архив + | experiments/ # артефакты прогонов (вне индексации) + | logo/ # картинки репозитория + | [^/]+\.(md|txt|rst)$ # корневые README/AGENTS/реестры + | .*/[^/]+\.(md|txt|rst)$ # markdown в любой папке (включая docs/*/…) + )""", + re.VERBOSE, +) + +EMPTY_TREE = "0000000000000000000000000000000000000000" + + +def changed_files(root: Path, base: str) -> list[str] | None: + """Файлы, изменённые от base до HEAD; None — база неизвестна.""" + if not base or set(base) == {"0"}: + return None + for candidate in (base, f"origin/{base}"): + proc = subprocess.run( + ["git", "diff", "--name-only", candidate, "HEAD"], + cwd=str(root), capture_output=True, text=True, + encoding="utf-8", errors="replace", + ) + if proc.returncode == 0: + return [line.strip() for line in proc.stdout.splitlines() if line.strip()] + return None + + +def has_code(files: list[str]) -> bool: + return any(not TEXT_PATHS.match(f.replace("\\", "/")) for f in files) + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("base", nargs="?", default="") + ap.add_argument("--root", default=str(Path(__file__).resolve().parents[1])) + args = ap.parse_args() + + files = changed_files(Path(args.root).resolve(), args.base) + if files is None: + print("true") + print("ci-changed-code: base unknown — treating as code change", file=sys.stderr) + return 0 + verdict = "true" if has_code(files) else "false" + print(verdict) + print(f"ci-changed-code: {len(files)} file(s), code={verdict}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_ci_changed_code.py b/tests/test_ci_changed_code.py new file mode 100644 index 00000000..d8535bc7 --- /dev/null +++ b/tests/test_ci_changed_code.py @@ -0,0 +1,88 @@ +"""Экономия CI: фильтр «менялся ли код» и структура workflow. + +Замер 2026-11-15: один прогон CI = 12.6 runner-минут (7 job'ов), из них 4.7 — +тяжёлые job'ы, которые docs-only PR не требует; 28 из 46 PR-прогонов были +перезапусками одного дерева (не было `cancel-in-progress`). +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import yaml + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "scripts")) + +import ci_changed_code as classifier # noqa: E402 + +WORKFLOW = REPO / ".github" / "workflows" / "ci.yml" +HEAVY_JOBS = ("clean-state", "manifest-parity", "transport-equiv-e2e", "docker-build") + + +def test_docs_and_registries_are_not_code(): + assert classifier.has_code( + ["docs/en/INSTALL.md", "README.md", "AGENT_DIARY.md", "KNOWN_ISSUES.md"] + ) is False + + +def test_source_and_config_are_code(): + assert classifier.has_code(["src/core/config.py"]) is True + assert classifier.has_code(["install.py"]) is True + assert classifier.has_code(["pyproject.toml"]) is True + + +def test_one_source_file_among_docs_still_counts_as_code(): + assert classifier.has_code(["docs/en/INSTALL.md", "src/main.py"]) is True + + +def test_experiments_and_images_are_not_code(): + assert classifier.has_code(["experiments/e1/REPORT.md", "experiments/e1/run.json"]) is False + assert classifier.has_code(["logo/logo.png", "docs/blog/assets/cover.png"]) is False + + +def test_empty_change_is_not_code(): + assert classifier.has_code([]) is False + + +def test_unknown_base_fails_open(): + proc = subprocess.run( + [sys.executable, str(REPO / "scripts" / "ci_changed_code.py"), "does-not-exist"], + cwd=str(REPO), capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert proc.returncode == 0 + assert proc.stdout.strip() == "true", proc.stderr + + +def test_head_against_itself_is_not_code(): + proc = subprocess.run( + [sys.executable, str(REPO / "scripts" / "ci_changed_code.py"), "HEAD"], + cwd=str(REPO), capture_output=True, text=True, encoding="utf-8", errors="replace", + ) + assert proc.returncode == 0 + assert proc.stdout.strip() == "false", proc.stderr + + +def test_superseded_pr_runs_are_cancelled(): + doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + concurrency = doc.get("concurrency") or {} + assert "github.ref" in json_dump(concurrency), concurrency + assert "pull_request" in json_dump(concurrency), "cancel must be scoped to PRs" + assert "github.event_name == 'pull_request'" in (concurrency.get("cancel-in-progress") or "") + + +def test_heavy_jobs_wait_for_the_change_filter(): + doc = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8")) + assert "changes" in doc["jobs"] + for job in HEAVY_JOBS: + spec = doc["jobs"][job] + assert spec.get("needs") == "changes", job + assert "needs.changes.outputs.code" in (spec.get("if") or ""), job + + +def json_dump(value) -> str: + import json + + return json.dumps(value) diff --git a/tests/test_microtask_pipeline_red_team.py b/tests/test_microtask_pipeline_red_team.py index 46905f1b..cae4a068 100644 --- a/tests/test_microtask_pipeline_red_team.py +++ b/tests/test_microtask_pipeline_red_team.py @@ -33,5 +33,10 @@ def test_every_attack_is_defended(): def test_red_team_covers_the_documented_attack_set(): - # the table in the README lists 13 attacks; the suite must not silently shrink + # the table in the README lists 13 attacks; the suite must not silently shrink. + # Runs the red team itself instead of reading module state: under xdist the + # two tests land in different workers, and a shared global is empty there + # (caught 2026-11-15: 2560 passed, this one failed with RESULTS == []). + red_team.RESULTS.clear() + assert red_team.main() == 0 assert len(red_team.RESULTS) == 13