Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Merge-драйвер для дневника: запись только добавляется, поэтому конфликт
# "оба правы" — склеиваем обе стороны. Без этого каждый PR конфликтует в
# AGENT_DIARY.md и требует ещё один merge + ещё один прогон CI
# (2026-11-15: четыре PR подряд, каждый — заново).
AGENT_DIARY.md merge=union
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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.
Expand All @@ -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).
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions AGENT_DIARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 файла дешевле любых процессов.
76 changes: 76 additions & 0 deletions scripts/ci_changed_code.py
Original file line number Diff line number Diff line change
@@ -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 <base-sha> [--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())
88 changes: 88 additions & 0 deletions tests/test_ci_changed_code.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 6 additions & 1 deletion tests/test_microtask_pipeline_red_team.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading