From 7c405e05a4ae1519adaf3972eb31c61af7491262 Mon Sep 17 00:00:00 2001 From: Igor Apresov Date: Wed, 12 Aug 2026 14:14:04 +0500 Subject: [PATCH 1/6] docs: design Windows external publication contract --- ...al-artifact-publication-contract-design.md | 121 ++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md diff --git a/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md b/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md new file mode 100644 index 000000000..b12d282be --- /dev/null +++ b/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md @@ -0,0 +1,121 @@ +- Date: `2026-08-12` +- Status: `approved` +- Decision: `none` — no architectural contract changed + +# Windows-контракт публикации внешних артефактов v8-runner + +## Контекст + +Unica 0.11.0 поставляет `v8-runner` 0.5.1 из commit +`72d346c0a8fcf8373d9388257d11e6bef0ad70b2`. На Windows команда `make` +успешно формирует и проверяет staged EPF/ERF, но затем может завершиться кодом +3 при публикации каталога `output`: старый runner открывает родительский каталог +как обычный файл перед `fsync`, получает `ERROR_ACCESS_DENIED` или +`ERROR_PATH_NOT_FOUND` и запускает rollback. Это пользовательский дефект из +#310 и его EPF-воспроизведение #264. + +Корневая причина уже исправлена в `alkoleft/v8-runner-rust#48`: вне Unix +directory fsync становится успешной no-op, а ошибки создания, записи и rename +не подавляются. Unica PR #419 обновил lock и бинарные assets до upstream commit +`7ce1b062843d86644fe55741dbe0ee79f7ca767d`, содержащего исправление. Однако +consumer-level контракт Unica проверяет запуск внешнего EPF, но не выполняет +`v8-runner make` до финальной публикации каталога. Поэтому поставка может снова +закрепить старый или регрессировавший бинарник, не получив падающего CI. + +## Цель + +Добавить воспроизводимый Windows-контракт для поставляемого `v8-runner`, который +проходит весь путь `make` для внешней обработки до публикации output-каталога и +отличает исправленный бинарник от поставленного в Unica 0.11.0. + +## Выбранный подход + +Расширить `scripts/ci/check-tool-contracts.py` отдельной проверкой Windows +external publication. Проверка запускает настоящий бинарник из проверяемого +runtime, но заменяет платформу 1С небольшим Rust-stub, собранным во временном +каталоге. Stub реализует только наблюдаемые Designer batch-вызовы, нужные +`make`: + +1. при `/LoadExternalDataProcessorOrReportFromFiles ` пишет + детерминированный непустой staged EPF; +2. при `/DumpExternalDataProcessorOrReportToFiles ` пишет + минимальный XML с тем же именем объекта, чтобы runner подтвердил артефакт; +3. при `/Out ` создаёт ожидаемый журнал и завершает вызов кодом 0. + +Временный `v8project.yaml` использует `format: DESIGNER`, +`builder: DESIGNER`, source-set `EXTERNAL_DATA_PROCESSORS`, явный локальный +`tools.platform.path` к stub и файловую строку подключения. Исходники содержат +один минимальный descriptor внешней обработки. Реальная платформа, лицензия и +пользовательская информационная база не нужны. + +Проверка выполняется только для target `win-x64`, потому что дефект зависит от +Windows directory-handle semantics. Она запускает два последовательных `make`: + +- первый публикует staged-каталог в отсутствующий относительный output; +- второй заменяет уже существующий output через backup/rollback boundary. + +После каждого запуска проверка требует код 0, валидный JSON-envelope, заявленный +EPF в artifacts, существующий непустой файл в output и отсутствие принадлежащих +этому запуску `.artifacts-stage-*`, `.artifacts-backup-*` и metadata-sidecar. +Второй запуск также доказывает, что старое содержимое target действительно +заменено. + +## Воспроизведение и доказательство исправления + +До изменения тест запускается вручную тем же helper-кодом против двух +immutable бинарников: + +- runtime Unica 0.11.0, source commit `72d346c0...`: ожидается устойчивый отказ + на Windows directory fsync после успешного platform-stub шага; +- текущий lock Unica, source commit `7ce1b062...`: ожидаются обе успешные + публикации и полная очистка временных единиц. + +В PR фиксируются команды, exit codes и существенная диагностика обоих запусков. +Старый бинарник и его байты в репозиторий не добавляются. + +## Рассмотренные альтернативы + +### Новый upstream fix + +Отклонён: production-исправление и Windows unit regression уже слиты в +`v8-runner-rust#48`. Новый PR дублировал бы существующее изменение и не защищал +бы consumer lock Unica. + +### Live smoke с установленной платформой 1С + +Отклонён для обязательного CI: тест зависел бы от закрытой платформы, лицензии, +версии установки и состояния информационной базы. Локальное live-воспроизведение +может дополнять PR evidence, но не заменяет детерминированный contract test. + +### Только проверка commit/hash в tools.lock + +Отклонена: provenance доказывает происхождение байтов, но не пользовательское +поведение `make`. Нужен исполняемый контракт финальной публикации. + +## Ошибки и границы + +- Проверка не подавляет ошибки platform-stub, JSON-протокола, staging, rename, + публикации или очистки; каждая возвращается с префиксом + `v8-runner Windows external publication contract`. +- Skip разрешён только для target, отличного от `win-x64`; отсутствие runner, + `rustc` или ожидаемого результата на Windows является failure. +- Проверка не меняет CLI/MCP surface, tool lock, runtime budgets или политику + staged publication. Она закрепляет уже поставленное исправление. + +## Проверка + +1. Новый unit-тест Python проверяет формирование fixture, разбор envelope и + диагностические ошибки helper-а без запуска внешнего процесса там, где это + можно изолировать. +2. Windows contract запускается против runtime 0.11.0 и обязан воспроизвести + исходный отказ до принятия текущего locked binary как baseline. +3. Та же проверка проходит против текущего `win-x64` asset. +4. `tests/ci/test_product_contracts.py`, `check-tool-contracts.py` для Windows, + `git diff --check` и обязательный `Unica CI` проходят. + +## Архитектурное влияние + +Новая ADR не требуется. Публичные инструменты, аргументы, результаты, +идентичность MCP-сервера, владение runtime и контракт упаковки не меняются. +Проверка лишь добавляет исполняемое доказательство уже принятого и поставленного +Windows-поведения внешнего `v8-runner`. From 33097d8d3d4f7d57265fc082e227d7e9610183c6 Mon Sep 17 00:00:00 2001 From: Igor Apresov Date: Wed, 12 Aug 2026 14:25:16 +0500 Subject: [PATCH 2/6] docs: plan Windows external publication contract --- ...-external-artifact-publication-contract.md | 501 ++++++++++++++++++ 1 file changed, 501 insertions(+) create mode 100644 docs/plans/2026-08-12-windows-external-artifact-publication-contract.md diff --git a/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md b/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md new file mode 100644 index 000000000..312bcc5ee --- /dev/null +++ b/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md @@ -0,0 +1,501 @@ +# Windows External Artifact Publication Contract Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a deterministic Windows consumer contract that reproduces the Unica 0.11.0 `v8-runner make` directory-fsync failure and proves the currently locked runner publishes and replaces external EPF output directories cleanly. + +**Architecture:** Extend the existing packaged-tool contract script with one Windows-only end-to-end check. It compiles a tiny Rust Designer stub in a temporary directory, runs the real packaged `v8-runner` twice against a one-object `EXTERNAL_DATA_PROCESSORS` source set, and validates the JSON envelope, published bytes, replacement semantics, and cleanup. Python unit tests protect result validation and ensure the Windows check is wired into targeted tool contracts. + +**Tech Stack:** Python 3.12 standard library, `unittest`, Rust `rustc` platform stub, packaged `v8-runner` 0.5.1, Windows filesystem semantics. + +## Global Constraints + +- Work only on branch `codex/issue-310-windows-publication-contract` in the isolated issue worktree. +- Preserve the public `unica.*` MCP surface, tool lock, package manifests, runtime budgets, and staged-publication policy. +- Direct `v8-runner` execution is limited to this maintainer/packaged-tool contract; user-facing guidance remains MCP-first. +- Do not require a real 1C installation, license, credentials, network, or persistent information base. +- The check is mandatory only when `target == "win-x64"`; non-Windows targeted runs do not execute it. +- The old runtime is diagnostic input only and is never copied into the repository. +- Every defect-facing production helper is introduced through a witnessed RED test. + +--- + +### Task 1: Define the external publication result contract + +**Files:** +- Modify: `tests/ci/test_product_contracts.py` +- Modify: `scripts/ci/check-tool-contracts.py` + +**Interfaces:** +- Consumes: a parsed runner JSON envelope, resolved output directory, expected EPF path and bytes, and the fixture root. +- Produces: `validate_v8_runner_windows_external_publication_result(envelope: object, output_dir: Path, expected_epf: Path, expected_bytes: bytes, fixture_root: Path) -> list[str]`. + +- [ ] **Step 1: Write the failing validator tests** + +Add two tests to `ProductContractTests`. The positive fixture uses hand-derived literals and real files: + +```python +def test_v8_runner_windows_external_publication_result_accepts_clean_epf(self) -> None: + module = load_contract_module() + validator = getattr( + module, + "validate_v8_runner_windows_external_publication_result", + None, + ) + self.assertIsNotNone(validator) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / "Deploy" + epf = output / "Alpha.epf" + output.mkdir() + epf.write_bytes(b"issue-310-current") + envelope = { + "ok": True, + "command": "make", + "data": { + "ok": True, + "mode": "external_data_processor_epf", + "source_set": "external-processors", + "output_path": str(output), + "artifacts": { + "root_dir": str(output), + "items": [ + { + "kind": "package", + "path": str(epf), + "role": "package_file", + } + ], + }, + "execution": { + "status": "succeeded", + "payload": { + "artifact_type": "external_data_processor_epf", + "output_path": str(output), + "file_names": ["Alpha.epf"], + "published": True, + }, + }, + }, + } + + self.assertEqual( + validator(envelope, output, epf, b"issue-310-current", root), + [], + ) +``` + +The negative fixture is explicit and catches three independent mutations: + +```python +def test_v8_runner_windows_external_publication_result_rejects_failed_or_dirty_publish( + self, +) -> None: + module = load_contract_module() + validator = module.validate_v8_runner_windows_external_publication_result + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / "Deploy" + epf = output / "Alpha.epf" + output.mkdir() + epf.write_bytes(b"issue-310-stale") + (root / ".artifacts-stage-leftover").mkdir() + envelope = { + "ok": False, + "data": { + "ok": False, + "mode": "external_data_processor_epf", + "source_set": "external-processors", + "output_path": str(output), + "execution": {"status": "failed"}, + }, + } + + errors = validator(envelope, output, epf, b"issue-310-current", root) + + self.assertTrue(any("envelope" in error for error in errors), errors) + self.assertTrue(any("unexpected bytes" in error for error in errors), errors) + self.assertTrue(any("temporary state" in error for error in errors), errors) +``` + +- [ ] **Step 2: Run the validator tests and witness RED** + +Run: + +```powershell +python tests/ci/test_product_contracts.py ProductContractTests.test_v8_runner_windows_external_publication_result_accepts_clean_epf ProductContractTests.test_v8_runner_windows_external_publication_result_rejects_failed_or_dirty_publish +``` + +Expected: FAIL because `validate_v8_runner_windows_external_publication_result` does not exist. The failure must not be an import, fixture, or syntax error. + +- [ ] **Step 3: Implement the minimal validator** + +Add the function beside the existing v8-runner validators. It must validate literal consumer behavior: + +```python +def validate_v8_runner_windows_external_publication_result( + envelope: object, + output_dir: Path, + expected_epf: Path, + expected_bytes: bytes, + fixture_root: Path, +) -> list[str]: + errors: list[str] = [] + data = envelope.get("data") if isinstance(envelope, dict) else None + if not isinstance(envelope, dict) or envelope.get("ok") is not True: + errors.append("runner JSON envelope is not successful") + if not isinstance(data, dict) or data.get("ok") is not True: + errors.append("runner JSON data is not successful") + return errors + if data.get("mode") != "external_data_processor_epf": + errors.append(f"runner JSON mode is not external_data_processor_epf: {data.get('mode')!r}") + if data.get("source_set") != "external-processors": + errors.append(f"runner JSON source_set is not external-processors: {data.get('source_set')!r}") + actual_output = data.get("output_path") + if not isinstance(actual_output, str) or Path(actual_output).resolve() != output_dir.resolve(): + errors.append(f"runner JSON output_path does not resolve to {output_dir.resolve()}") + execution = data.get("execution") + if not isinstance(execution, dict) or execution.get("status") != "succeeded": + errors.append("runner execution status is not succeeded") + payload = execution.get("payload") if isinstance(execution, dict) else None + if not isinstance(payload, dict) or payload.get("published") is not True: + errors.append("runner execution payload is not published") + if not expected_epf.is_file(): + errors.append(f"published EPF was not created: {expected_epf}") + elif expected_epf.read_bytes() != expected_bytes: + errors.append(f"published EPF has unexpected bytes: {expected_epf}") + retained = sorted( + path.name + for path in fixture_root.iterdir() + if path.name.startswith((".artifacts-stage-", ".artifacts-backup-")) + or ( + path.name.startswith(".artifacts-") + and path.name.endswith(".meta.json") + ) + ) + if retained: + errors.append(f"publication temporary state was retained: {retained}") + return errors +``` + +Also verify `data.artifacts.items` contains a `package_file` whose resolved path equals `expected_epf`, and `payload.file_names` equals `['Alpha.epf']`. Keep filesystem read errors as returned diagnostic strings rather than uncaught exceptions. + +- [ ] **Step 4: Run the validator tests and witness GREEN** + +Run the command from Step 2. + +Expected: both tests PASS. + +- [ ] **Step 5: Commit the validator cycle** + +```powershell +git add tests/ci/test_product_contracts.py scripts/ci/check-tool-contracts.py +git commit -m "test: define Windows external publication result contract" +``` + +--- + +### Task 2: Execute packaged `v8-runner make` on Windows + +**Files:** +- Modify: `tests/ci/test_product_contracts.py` +- Modify: `scripts/ci/check-tool-contracts.py` + +**Interfaces:** +- Consumes: the packaged runner path and target name passed by `check_tool_contracts`. +- Produces: `check_v8_runner_windows_external_publication_contract(runner: Path, target: str) -> list[str]`; returns no errors for non-`win-x64` targets. + +- [ ] **Step 1: Write the failing routing test** + +Extend the targeted tool-contract test so the real dispatcher is exercised while only external processes are replaced: + +```python +with ( + patch.object(module, "TOOL_HELP_CHECKS", []), + patch.object(module, "check_v8_runner_partial_load_contract", return_value=[]), + patch.object(module, "check_v8_runner_bounded_external_epf_contract", return_value=[]), + patch.object( + module, + "check_v8_runner_windows_external_publication_contract", + return_value=["windows publication failure"], + ) as publication_check, +): + errors = module.check_tool_contracts(tools_dir, "win-x64") + +self.assertEqual(errors, ["windows publication failure"]) +publication_check.assert_called_once_with(runner.resolve(), "win-x64") +``` + +Use `v8-runner.exe` as the fixture filename so the production resolver chooses the same path as Windows packaging. + +- [ ] **Step 2: Run the routing test and witness RED** + +Run: + +```powershell +python tests/ci/test_product_contracts.py ProductContractTests.test_targeted_tool_contracts_run_windows_external_publication_smoke +``` + +Expected: FAIL because the Windows external publication check is absent from the dispatcher. + +- [ ] **Step 3: Add the Windows contract implementation** + +Add `check_v8_runner_windows_external_publication_contract` after the bounded EPF check with this exact boundary and early validation: + +```python +def check_v8_runner_windows_external_publication_contract( + runner: Path, + target: str, +) -> list[str]: + label = "v8-runner Windows external publication contract" + if target != "win-x64": + return [] + if not runner.is_file(): + return [f"{label}: binary not found: {runner}"] +``` + +After the guards, create one `TemporaryDirectory(prefix="unica-v8-runner-310-")`, then create `src/external-processors`, `work`, `ib`, and `platform/bin`. The descriptor is exactly: + +```xml +Alpha +``` + +The stub parses its own arguments and performs these real side effects: + +```rust +if argument.eq_ignore_ascii_case("/LoadExternalDataProcessorOrReportFromFiles") { + fs::write(&arguments[index + 2], b"issue-310-current")?; +} +if argument.eq_ignore_ascii_case("/DumpExternalDataProcessorOrReportToFiles") { + fs::write( + &arguments[index + 1], + b"Alpha", + )?; +} +if argument.eq_ignore_ascii_case("/Out") { + fs::write(&arguments[index + 1], b"issue-310-platform-ok\n")?; +} +``` + +Compile the same executable to `platform/bin/1cv8c.exe` and copy it to `platform/bin/1cv8.exe`. The config contains: + +```yaml +workPath: '' +execution_timeout: 30000 +format: DESIGNER +builder: DESIGNER +infobase: + connection: 'File=' +source-set: + - name: external-processors + type: EXTERNAL_DATA_PROCESSORS + path: '' +tools: + platform: + path: '' +``` + +Invoke: + +```python +command = [ + str(runner), + "--config", str(config), + "--json-message", + "make", + "--source-set", "external-processors", + "--output", "Deploy", +] +``` + +Run from the temporary fixture root with a 60-second process timeout. Parse stdout as JSON. Validate the first result, then write `Deploy/stale.epf` and overwrite `Deploy/Alpha.epf` with `b"issue-310-stale"`; run the same command again and validate that only `Alpha.epf` remains with `b"issue-310-current"`. The process branch is exact: + +```python +def run_make() -> tuple[object | None, list[str]]: + try: + result = subprocess.run( + command, + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired: + return None, [f"{label}: runner did not exit within 60 seconds"] + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() + return None, [ + f"{label}: runner OS process exited with {result.returncode}: {detail}" + ] + try: + return json.loads(result.stdout), [] + except json.JSONDecodeError as error: + return None, [f"{label}: runner returned invalid JSON: {error}"] +``` + +Prefix compile and result errors with `label`. After the second validation, require `sorted(path.name for path in output.iterdir()) == ["Alpha.epf"]` so replacement cannot leave `stale.epf` behind. + +Finally append the check in `check_tool_contracts` after the two existing v8-runner behavioral checks. + +- [ ] **Step 4: Run the routing test and witness GREEN** + +Run the command from Step 2. + +Expected: PASS. + +- [ ] **Step 5: Run all product-contract unit tests** + +Run: + +```powershell +python tests/ci/test_product_contracts.py +``` + +Expected: all tests related to the changed helpers pass. If the known Windows-only `/missing/v8-runner` path spelling assertion fails, confirm it also fails on unmodified `origin/main` and record it separately; do not weaken it in this PR. + +- [ ] **Step 6: Commit the executable Windows contract** + +```powershell +git add tests/ci/test_product_contracts.py scripts/ci/check-tool-contracts.py +git commit -m "test: run Windows external publication contract" +``` + +--- + +### Task 3: Prove RED on 0.11.0 and GREEN on the current lock + +**Files:** +- Verify only: `scripts/ci/check-tool-contracts.py` +- Verify only: `plugins/unica/third-party/tools.lock.json` + +**Interfaces:** +- Consumes: installed Unica 0.11.0 runner from source commit `72d346c0a8fcf8373d9388257d11e6bef0ad70b2` and immutable current asset `v8-runner-nightly-master-build.2` from source commit `7ce1b062843d86644fe55741dbe0ee79f7ca767d`. +- Produces: reproducible PR evidence containing both binary provenance and the opposite contract outcomes. + +- [ ] **Step 1: Verify old binary provenance** + +Read `C:\Users\IApresov\.codex\unica\runtimes\0.11.0\win-x64\third-party\manifest.json` and require: + +```text +name = v8-runner +version = 0.5.1 +sourceCommit = 72d346c0a8fcf8373d9388257d11e6bef0ad70b2 +``` + +- [ ] **Step 2: Run the contract against the old binary and witness issue #310** + +Import `scripts/ci/check-tool-contracts.py` with `importlib.util` and invoke only: + +```python +check_v8_runner_windows_external_publication_contract( + Path(r"C:\Users\IApresov\.codex\unica\runtimes\0.11.0\win-x64\bin\win-x64\v8-runner.exe"), + "win-x64", +) +``` + +Expected: a non-empty error list whose process diagnostic contains exit code 3 and the directory publication/fsync failure (`os error 3` or `os error 5`). Confirm the stub wrote its platform marker before publication failed. + +- [ ] **Step 3: Download and verify the current immutable Windows asset** + +Download `v8-runner-win-x64.exe` and its checksum from GitHub release `IngvarConsulting/unica-toolchain@v8-runner-nightly-master-build.2` into an ignored temporary directory under `.build/issue-310-current-runner`. Verify SHA-256 equals the lock value: + +```text +191a3d7c930007377238dda0543d1e42cc1a1bd4b209736d54fd41c0ffaac32e +``` + +Resolve and validate the absolute temporary path before any cleanup. + +- [ ] **Step 4: Run the same contract against the current asset** + +Invoke the same helper with the downloaded binary and `win-x64`. + +Expected: `[]`; both new-target and replacement publication pass, no stage/backup/metadata residue remains. + +- [ ] **Step 5: Retain only the verified ignored download through final verification** + +Keep `.build/issue-310-current-runner` only until Task 4 repeats the GREEN contract. It is ignored and must not be staged. Do not copy the binary elsewhere or modify any runtime installation. + +--- + +### Task 4: Final verification and pull request + +**Files:** +- Verify: `docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md` +- Verify: `docs/plans/2026-08-12-windows-external-artifact-publication-contract.md` +- Verify: `scripts/ci/check-tool-contracts.py` +- Verify: `tests/ci/test_product_contracts.py` + +**Interfaces:** +- Consumes: completed commits and RED/GREEN evidence. +- Produces: one independently reviewable PR to `IngvarConsulting/unica:main` linked to #310 and #264. + +- [ ] **Step 1: Run formatting and static checks** + +```powershell +python -m py_compile scripts/ci/check-tool-contracts.py tests/ci/test_product_contracts.py +git diff --check origin/main...HEAD +python scripts/ci/check-rust-platform-boundary.py +python scripts/ci/check-architecture-sync.py --base origin/main +``` + +- [ ] **Step 2: Run focused and repository-level tests** + +```powershell +python tests/ci/test_product_contracts.py +python tests/ci/test_design_documents.py +python tests/ci/test_architecture_registry.py +``` + +For any baseline failure, run the exact test on `origin/main` in a clean detached worktree and preserve the comparison in the PR evidence. + +- [ ] **Step 3: Run the current packaged Windows contract directly** + +Run `check_v8_runner_windows_external_publication_contract` against the SHA-verified current asset one final time. + +Expected: `[]`. Then resolve `.build/issue-310-current-runner`, require that it is a child of this worktree's `.build`, and remove only that verified directory. Do not delete any runtime installation or user worktree. + +- [ ] **Step 4: Review scope and commits** + +```powershell +git status --short +git diff --check origin/main...HEAD +git diff --stat origin/main...HEAD +git log --oneline origin/main..HEAD +``` + +Expected tracked scope: one design, one plan, `check-tool-contracts.py`, and `test_product_contracts.py`. No binaries, generated fixture, logs, credentials, runtime caches, or live-IB files are tracked. + +- [ ] **Step 5: Commit any final test-only adjustments** + +```powershell +git add scripts/ci/check-tool-contracts.py tests/ci/test_product_contracts.py docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md docs/plans/2026-08-12-windows-external-artifact-publication-contract.md +git commit -m "test: protect Windows artifact publication" +``` + +Skip this commit when the index is empty; do not create an empty commit. + +- [ ] **Step 6: Push and open the pull request** + +Push `codex/issue-310-windows-publication-contract` and open a non-draft PR against `main` with: + +```markdown +Closes #310. +Related: #264. + +The production fix already arrived through upstream v8-runner-rust#48 and the +locked build.2 refresh. This PR adds the missing Windows consumer regression. + +RED: Unica 0.11.0 runner (`72d346c0a8fcf8373d9388257d11e6bef0ad70b2`) exits 3 after successful stub build +when directory publication reaches fsync. + +GREEN: current locked runner (`7ce1b062843d86644fe55741dbe0ee79f7ca767d`, SHA-256 `191a3d7c930007377238dda0543d1e42cc1a1bd4b209736d54fd41c0ffaac32e`) publishes a +new output, replaces an existing output, and leaves no stage/backup metadata. +``` + +- [ ] **Step 7: Verify remote PR state** + +Require that the PR head SHA equals local `HEAD`, base is `main`, the PR is open and non-draft, and initial GitHub checks are present. Report the PR URL and any pending checks without claiming CI success before completion. From da6ce6303b3e40d2651b8d29d04f8e8a9bef2da7 Mon Sep 17 00:00:00 2001 From: Igor Apresov Date: Wed, 12 Aug 2026 14:26:53 +0500 Subject: [PATCH 3/6] test: define Windows external publication result contract --- scripts/ci/check-tool-contracts.py | 117 +++++++++++++++++++++++++++++ tests/ci/test_product_contracts.py | 88 ++++++++++++++++++++++ 2 files changed, 205 insertions(+) diff --git a/scripts/ci/check-tool-contracts.py b/scripts/ci/check-tool-contracts.py index dd1ddb8a5..c0dbd7564 100644 --- a/scripts/ci/check-tool-contracts.py +++ b/scripts/ci/check-tool-contracts.py @@ -292,6 +292,123 @@ def validate_v8_runner_bounded_external_epf_result( return errors +def validate_v8_runner_windows_external_publication_result( + envelope: object, + output_dir: Path, + expected_epf: Path, + expected_bytes: bytes, + fixture_root: Path, +) -> list[str]: + errors: list[str] = [] + data = envelope.get("data") if isinstance(envelope, dict) else None + + if not isinstance(envelope, dict) or envelope.get("ok") is not True: + errors.append("runner JSON envelope is not successful") + if isinstance(envelope, dict) and envelope.get("command") != "make": + errors.append( + f"runner JSON command is not make: {envelope.get('command')!r}" + ) + if not isinstance(data, dict) or data.get("ok") is not True: + errors.append("runner JSON data is not successful") + + if isinstance(data, dict): + if data.get("mode") != "external_data_processor_epf": + errors.append( + "runner JSON mode is not external_data_processor_epf: " + f"{data.get('mode')!r}" + ) + if data.get("source_set") != "external-processors": + errors.append( + "runner JSON source_set is not external-processors: " + f"{data.get('source_set')!r}" + ) + + actual_output = data.get("output_path") + if not isinstance(actual_output, str): + errors.append("runner JSON output_path is not a path string") + else: + try: + output_matches = Path(actual_output).resolve() == output_dir.resolve() + except OSError as error: + errors.append(f"runner JSON output_path could not be resolved: {error}") + else: + if not output_matches: + errors.append( + "runner JSON output_path does not resolve to " + f"{output_dir.resolve()}" + ) + + artifacts = data.get("artifacts") + items = artifacts.get("items") if isinstance(artifacts, dict) else None + package_paths: list[Path] = [] + if isinstance(items, list): + for item in items: + if not isinstance(item, dict) or item.get("role") != "package_file": + continue + path = item.get("path") + if isinstance(path, str): + package_paths.append(Path(path)) + try: + expected_resolved = expected_epf.resolve() + package_matches = any( + path.resolve() == expected_resolved for path in package_paths + ) + except OSError as error: + errors.append(f"runner JSON package artifact path could not be resolved: {error}") + else: + if not package_matches: + errors.append( + "runner JSON artifacts do not contain the expected package_file: " + f"{expected_epf}" + ) + + execution = data.get("execution") + if not isinstance(execution, dict) or execution.get("status") != "succeeded": + errors.append("runner execution status is not succeeded") + payload = execution.get("payload") if isinstance(execution, dict) else None + if not isinstance(payload, dict) or payload.get("published") is not True: + errors.append("runner execution payload is not published") + if isinstance(payload, dict): + if payload.get("artifact_type") != "external_data_processor_epf": + errors.append( + "runner execution artifact_type is not external_data_processor_epf" + ) + if payload.get("file_names") != [expected_epf.name]: + errors.append( + "runner execution file_names do not match the published EPF: " + f"{payload.get('file_names')!r}" + ) + + if not expected_epf.is_file(): + errors.append(f"published EPF was not created: {expected_epf}") + else: + try: + actual_bytes = expected_epf.read_bytes() + except OSError as error: + errors.append(f"published EPF could not be read: {error}") + else: + if actual_bytes != expected_bytes: + errors.append(f"published EPF has unexpected bytes: {expected_epf}") + + try: + retained = sorted( + path.name + for path in fixture_root.iterdir() + if path.name.startswith((".artifacts-stage-", ".artifacts-backup-")) + or ( + path.name.startswith(".artifacts-") + and path.name.endswith(".meta.json") + ) + ) + except OSError as error: + errors.append(f"publication temporary state could not be inspected: {error}") + else: + if retained: + errors.append(f"publication temporary state was retained: {retained}") + + return errors + + def check_v8_runner_bounded_external_epf_contract( runner: Path, target: str, diff --git a/tests/ci/test_product_contracts.py b/tests/ci/test_product_contracts.py index fa6a25c09..ad992148c 100644 --- a/tests/ci/test_product_contracts.py +++ b/tests/ci/test_product_contracts.py @@ -222,6 +222,94 @@ def test_v8_runner_bounded_external_epf_result_rejects_broken_wait_contract( errors, ) + def test_v8_runner_windows_external_publication_result_accepts_clean_epf( + self, + ) -> None: + module = load_contract_module() + validator = getattr( + module, + "validate_v8_runner_windows_external_publication_result", + None, + ) + self.assertIsNotNone(validator) + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / "Deploy" + epf = output / "Alpha.epf" + output.mkdir() + epf.write_bytes(b"issue-310-current") + envelope = { + "ok": True, + "command": "make", + "data": { + "ok": True, + "mode": "external_data_processor_epf", + "source_set": "external-processors", + "output_path": str(output), + "artifacts": { + "root_dir": str(output), + "items": [ + { + "kind": "package", + "path": str(epf), + "role": "package_file", + } + ], + }, + "execution": { + "status": "succeeded", + "payload": { + "artifact_type": "external_data_processor_epf", + "output_path": str(output), + "file_names": ["Alpha.epf"], + "published": True, + }, + }, + }, + } + + self.assertEqual( + validator(envelope, output, epf, b"issue-310-current", root), + [], + ) + + def test_v8_runner_windows_external_publication_result_rejects_failed_or_dirty_publish( + self, + ) -> None: + module = load_contract_module() + validator = module.validate_v8_runner_windows_external_publication_result + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + output = root / "Deploy" + epf = output / "Alpha.epf" + output.mkdir() + epf.write_bytes(b"issue-310-stale") + (root / ".artifacts-stage-leftover").mkdir() + envelope = { + "ok": False, + "data": { + "ok": False, + "mode": "external_data_processor_epf", + "source_set": "external-processors", + "output_path": str(output), + "execution": {"status": "failed"}, + }, + } + + errors = validator( + envelope, + output, + epf, + b"issue-310-current", + root, + ) + + self.assertTrue(any("envelope" in error for error in errors), errors) + self.assertTrue(any("unexpected bytes" in error for error in errors), errors) + self.assertTrue(any("temporary state" in error for error in errors), errors) + def test_targeted_tool_contracts_run_both_v8_runner_behavioral_smokes(self) -> None: module = load_contract_module() From d4b5fb3c6312ec3b0248e2c2b85433b83d10c539 Mon Sep 17 00:00:00 2001 From: Igor Apresov Date: Wed, 12 Aug 2026 14:31:26 +0500 Subject: [PATCH 4/6] test: run Windows external publication contract --- scripts/ci/check-tool-contracts.py | 206 +++++++++++++++++++++++++++++ tests/ci/test_product_contracts.py | 32 +++++ 2 files changed, 238 insertions(+) diff --git a/scripts/ci/check-tool-contracts.py b/scripts/ci/check-tool-contracts.py index c0dbd7564..f0a7d46ba 100644 --- a/scripts/ci/check-tool-contracts.py +++ b/scripts/ci/check-tool-contracts.py @@ -552,6 +552,206 @@ def yaml_path(path: Path) -> str: ] +def check_v8_runner_windows_external_publication_contract( + runner: Path, + target: str, +) -> list[str]: + label = "v8-runner Windows external publication contract" + if target != "win-x64": + return [] + if not runner.is_file(): + return [f"{label}: binary not found: {runner}"] + + with tempfile.TemporaryDirectory(prefix="unica-v8-runner-310-") as directory: + root = Path(directory) + source_root = root / "src" / "external-processors" + work_path = root / "work" + infobase_path = root / "ib" + platform_root = root / "platform" + platform_bin = platform_root / "bin" + platform_marker = root / "platform-stub.marker" + source_root.mkdir(parents=True) + work_path.mkdir() + infobase_path.mkdir() + platform_bin.mkdir(parents=True) + (source_root / "Alpha.xml").write_text( + "Alpha" + "", + encoding="utf-8", + ) + + stub_source = root / "platform-stub.rs" + stub_source.write_text( + r''' +use std::{env, error::Error, fs}; + +fn main() -> Result<(), Box> { + let arguments: Vec<_> = env::args_os().skip(1).collect(); + if let Some(marker) = env::var_os("UNICA_V8_RUNNER_310_PLATFORM_MARKER") { + fs::write(marker, b"issue-310-platform-ok\n")?; + } + for (index, argument) in arguments.iter().enumerate() { + let argument = argument.to_string_lossy(); + if argument.eq_ignore_ascii_case("/LoadExternalDataProcessorOrReportFromFiles") { + fs::write(&arguments[index + 2], b"issue-310-current")?; + } + if argument.eq_ignore_ascii_case("/DumpExternalDataProcessorOrReportToFiles") { + fs::write( + &arguments[index + 1], + b"Alpha", + )?; + } + if argument.eq_ignore_ascii_case("/Out") { + fs::write(&arguments[index + 1], b"issue-310-platform-ok\n")?; + } + } + Ok(()) +} +'''.lstrip(), + encoding="utf-8", + ) + client_platform = platform_bin / "1cv8c.exe" + gui_platform = platform_bin / "1cv8.exe" + compiled = subprocess.run( + ["rustc", "--edition=2021", str(stub_source), "-o", str(client_platform)], + cwd=root, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if compiled.returncode != 0: + return [ + f"{label}: failed to compile platform stub: {compiled.stderr.strip()}" + ] + shutil.copy2(client_platform, gui_platform) + + def yaml_path(path: Path) -> str: + return str(path).replace("'", "''") + + config = root / "v8project.yaml" + config.write_text( + "\n".join( + [ + f"workPath: '{yaml_path(work_path)}'", + "execution_timeout: 30000", + "format: DESIGNER", + "builder: DESIGNER", + "infobase:", + f" connection: 'File={yaml_path(infobase_path)}'", + "source-set:", + " - name: external-processors", + " type: EXTERNAL_DATA_PROCESSORS", + f" path: '{yaml_path(source_root)}'", + "tools:", + " platform:", + f" path: '{yaml_path(platform_root)}'", + "", + ] + ), + encoding="utf-8", + ) + output_dir = root / "Deploy" + expected_epf = output_dir / "Alpha.epf" + command = [ + str(runner), + "--config", + str(config), + "--json-message", + "make", + "--source-set", + "external-processors", + "--output", + "Deploy", + ] + + def run_make() -> tuple[object | None, list[str]]: + try: + platform_marker.unlink(missing_ok=True) + except OSError as error: + return None, [f"{label}: failed to reset platform marker: {error}"] + try: + result = subprocess.run( + command, + cwd=root, + env={ + **os.environ, + "UNICA_V8_RUNNER_310_PLATFORM_MARKER": str(platform_marker), + }, + text=True, + encoding="utf-8", + errors="replace", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=60, + check=False, + ) + except subprocess.TimeoutExpired: + return None, [f"{label}: runner did not exit within 60 seconds"] + + marker_state = "present" if platform_marker.is_file() else "missing" + if result.returncode != 0: + detail = "\n".join( + part.strip() + for part in (result.stdout, result.stderr) + if part.strip() + ) + return None, [ + f"{label}: runner OS process exited with {result.returncode}; " + f"platform stub marker={marker_state}: {detail}" + ] + if marker_state != "present": + return None, [ + f"{label}: runner succeeded without invoking the platform stub" + ] + try: + return json.loads(result.stdout), [] + except json.JSONDecodeError as error: + return None, [f"{label}: runner returned invalid JSON: {error}"] + + first_envelope, first_errors = run_make() + if first_errors: + return first_errors + first_result_errors = validate_v8_runner_windows_external_publication_result( + first_envelope, + output_dir, + expected_epf, + b"issue-310-current", + root, + ) + if first_result_errors: + return [f"{label}: first publish: {error}" for error in first_result_errors] + + try: + (output_dir / "stale.epf").write_bytes(b"issue-310-stale") + expected_epf.write_bytes(b"issue-310-stale") + except OSError as error: + return [f"{label}: failed to prepare replacement target: {error}"] + + second_envelope, second_errors = run_make() + if second_errors: + return second_errors + result_errors = validate_v8_runner_windows_external_publication_result( + second_envelope, + output_dir, + expected_epf, + b"issue-310-current", + root, + ) + if result_errors: + return [f"{label}: replacement publish: {error}" for error in result_errors] + try: + output_entries = sorted(path.name for path in output_dir.iterdir()) + except OSError as error: + return [f"{label}: published directory could not be inspected: {error}"] + if output_entries != ["Alpha.epf"]: + return [ + f"{label}: replacement publish retained unexpected files: " + f"{output_entries}" + ] + return [] + + def run_command( command: list[str], cwd: Path, @@ -638,6 +838,12 @@ def check_tool_contracts(tools_dir: Path, target: str | None = None) -> list[str target, ) ) + errors.extend( + check_v8_runner_windows_external_publication_contract( + tool_executable(tools_dir, "v8-runner", target), + target, + ) + ) return errors diff --git a/tests/ci/test_product_contracts.py b/tests/ci/test_product_contracts.py index ad992148c..aa6137a89 100644 --- a/tests/ci/test_product_contracts.py +++ b/tests/ci/test_product_contracts.py @@ -336,6 +336,38 @@ def test_targeted_tool_contracts_run_both_v8_runner_behavioral_smokes(self) -> N behavioral_check.assert_called_once_with(runner.resolve(), "linux-x64") bounded_check.assert_called_once_with(runner.resolve(), "linux-x64") + def test_targeted_tool_contracts_run_windows_external_publication_smoke( + self, + ) -> None: + module = load_contract_module() + + with tempfile.TemporaryDirectory() as tmp: + tools_dir = Path(tmp) + runner = tools_dir / "v8-runner.exe" + runner.write_bytes(b"runner") + with ( + patch.object(module, "TOOL_HELP_CHECKS", []), + patch.object( + module, + "check_v8_runner_partial_load_contract", + return_value=[], + ), + patch.object( + module, + "check_v8_runner_bounded_external_epf_contract", + return_value=[], + ), + patch.object( + module, + "check_v8_runner_windows_external_publication_contract", + return_value=["windows publication failure"], + ) as publication_check, + ): + errors = module.check_tool_contracts(tools_dir, "win-x64") + + self.assertEqual(errors, ["windows publication failure"]) + publication_check.assert_called_once_with(runner.resolve(), "win-x64") + BSL_ANALYZER_HELP = ( "#!/usr/bin/env sh\n" "case \"$*\" in\n" From cea9fca787dc02422203297e4f16bcba2fe96e83 Mon Sep 17 00:00:00 2001 From: Igor Apresov Date: Wed, 12 Aug 2026 14:36:01 +0500 Subject: [PATCH 5/6] test: resolve relative publication paths --- ...al-artifact-publication-contract-design.md | 13 +++++++--- ...-external-artifact-publication-contract.md | 10 +++---- scripts/ci/check-tool-contracts.py | 26 +++++++++++++------ tests/ci/test_product_contracts.py | 8 +++--- 4 files changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md b/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md index b12d282be..6023046c9 100644 --- a/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md +++ b/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md @@ -56,9 +56,16 @@ Windows directory-handle semantics. Она запускает два после После каждого запуска проверка требует код 0, валидный JSON-envelope, заявленный EPF в artifacts, существующий непустой файл в output и отсутствие принадлежащих -этому запуску `.artifacts-stage-*`, `.artifacts-backup-*` и metadata-sidecar. -Второй запуск также доказывает, что старое содержимое target действительно -заменено. +этому запуску `.artifacts-stage-*`, `.artifacts-backup-*` и их `.meta.json` +sidecar. Второй запуск также доказывает, что старый набор EPF в target +действительно заменён. + +Текущий locked asset дополнительно переносит `Alpha.epf.meta.json` внутрь +опубликованного output. Это предсуществующее кроссплатформенное поведение +upstream staging-публикатора, а не Windows-fsync дефект #310. Контракт не +закрепляет этот sidecar как обязательный и не втягивает его исправление в этот +PR: он требует ровно один опубликованный EPF и отдельно запрещает временные +`.artifacts-*` пути и sidecar. ## Воспроизведение и доказательство исправления diff --git a/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md b/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md index 312bcc5ee..66d4cb847 100644 --- a/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md +++ b/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md @@ -167,8 +167,8 @@ def validate_v8_runner_windows_external_publication_result( elif expected_epf.read_bytes() != expected_bytes: errors.append(f"published EPF has unexpected bytes: {expected_epf}") retained = sorted( - path.name - for path in fixture_root.iterdir() + str(path.relative_to(fixture_root)) + for path in fixture_root.rglob("*") if path.name.startswith((".artifacts-stage-", ".artifacts-backup-")) or ( path.name.startswith(".artifacts-") @@ -310,7 +310,7 @@ command = [ ] ``` -Run from the temporary fixture root with a 60-second process timeout. Parse stdout as JSON. Validate the first result, then write `Deploy/stale.epf` and overwrite `Deploy/Alpha.epf` with `b"issue-310-stale"`; run the same command again and validate that only `Alpha.epf` remains with `b"issue-310-current"`. The process branch is exact: +Run from the temporary fixture root with a 60-second process timeout. Parse stdout as JSON. Validate the first result, then write `Deploy/stale.epf` and overwrite `Deploy/Alpha.epf` with `b"issue-310-stale"`; run the same command again and validate that the only published EPF is `Alpha.epf` with `b"issue-310-current"`. The current upstream runner also publishes an unreported `Alpha.epf.meta.json` sidecar; that pre-existing cross-platform behavior is outside #310 and is neither required nor fixed by this PR. The process branch is exact: ```python def run_make() -> tuple[object | None, list[str]]: @@ -337,7 +337,7 @@ def run_make() -> tuple[object | None, list[str]]: return None, [f"{label}: runner returned invalid JSON: {error}"] ``` -Prefix compile and result errors with `label`. After the second validation, require `sorted(path.name for path in output.iterdir()) == ["Alpha.epf"]` so replacement cannot leave `stale.epf` behind. +Prefix compile and result errors with `label`. After the second validation, require `sorted(path.name for path in output.iterdir() if path.suffix.lower() == ".epf") == ["Alpha.epf"]` so replacement cannot leave `stale.epf` behind without making the upstream metadata sidecar part of Unica's required contract. Finally append the check in `check_tool_contracts` after the two existing v8-runner behavioral checks. @@ -413,7 +413,7 @@ Resolve and validate the absolute temporary path before any cleanup. Invoke the same helper with the downloaded binary and `win-x64`. -Expected: `[]`; both new-target and replacement publication pass, no stage/backup/metadata residue remains. +Expected: `[]`; both new-target and replacement publication pass, the only EPF is `Alpha.epf`, and no `.artifacts-stage-*`/backup metadata residue remains. - [ ] **Step 5: Retain only the verified ignored download through final verification** diff --git a/scripts/ci/check-tool-contracts.py b/scripts/ci/check-tool-contracts.py index f0a7d46ba..2aef0098b 100644 --- a/scripts/ci/check-tool-contracts.py +++ b/scripts/ci/check-tool-contracts.py @@ -302,6 +302,10 @@ def validate_v8_runner_windows_external_publication_result( errors: list[str] = [] data = envelope.get("data") if isinstance(envelope, dict) else None + def resolve_result_path(value: str) -> Path: + path = Path(value) + return path if path.is_absolute() else fixture_root / path + if not isinstance(envelope, dict) or envelope.get("ok") is not True: errors.append("runner JSON envelope is not successful") if isinstance(envelope, dict) and envelope.get("command") != "make": @@ -328,7 +332,9 @@ def validate_v8_runner_windows_external_publication_result( errors.append("runner JSON output_path is not a path string") else: try: - output_matches = Path(actual_output).resolve() == output_dir.resolve() + output_matches = ( + resolve_result_path(actual_output).resolve() == output_dir.resolve() + ) except OSError as error: errors.append(f"runner JSON output_path could not be resolved: {error}") else: @@ -347,7 +353,7 @@ def validate_v8_runner_windows_external_publication_result( continue path = item.get("path") if isinstance(path, str): - package_paths.append(Path(path)) + package_paths.append(resolve_result_path(path)) try: expected_resolved = expected_epf.resolve() package_matches = any( @@ -392,8 +398,8 @@ def validate_v8_runner_windows_external_publication_result( try: retained = sorted( - path.name - for path in fixture_root.iterdir() + str(path.relative_to(fixture_root)) + for path in fixture_root.rglob("*") if path.name.startswith((".artifacts-stage-", ".artifacts-backup-")) or ( path.name.startswith(".artifacts-") @@ -741,13 +747,17 @@ def run_make() -> tuple[object | None, list[str]]: if result_errors: return [f"{label}: replacement publish: {error}" for error in result_errors] try: - output_entries = sorted(path.name for path in output_dir.iterdir()) + output_packages = sorted( + path.name + for path in output_dir.iterdir() + if path.suffix.lower() == ".epf" + ) except OSError as error: return [f"{label}: published directory could not be inspected: {error}"] - if output_entries != ["Alpha.epf"]: + if output_packages != ["Alpha.epf"]: return [ - f"{label}: replacement publish retained unexpected files: " - f"{output_entries}" + f"{label}: replacement publish retained unexpected EPF files: " + f"{output_packages}" ] return [] diff --git a/tests/ci/test_product_contracts.py b/tests/ci/test_product_contracts.py index aa6137a89..02c677697 100644 --- a/tests/ci/test_product_contracts.py +++ b/tests/ci/test_product_contracts.py @@ -246,13 +246,13 @@ def test_v8_runner_windows_external_publication_result_accepts_clean_epf( "ok": True, "mode": "external_data_processor_epf", "source_set": "external-processors", - "output_path": str(output), + "output_path": "Deploy", "artifacts": { - "root_dir": str(output), + "root_dir": "Deploy", "items": [ { "kind": "package", - "path": str(epf), + "path": str(Path("Deploy") / "Alpha.epf"), "role": "package_file", } ], @@ -261,7 +261,7 @@ def test_v8_runner_windows_external_publication_result_accepts_clean_epf( "status": "succeeded", "payload": { "artifact_type": "external_data_processor_epf", - "output_path": str(output), + "output_path": "Deploy", "file_names": ["Alpha.epf"], "published": True, }, From 1bf8b2e446bb1643b8ae9abebd97dc5ac052ee42 Mon Sep 17 00:00:00 2001 From: Igor Apresov Date: Wed, 12 Aug 2026 14:53:21 +0500 Subject: [PATCH 6/6] test: bound platform stub compilation --- ...al-artifact-publication-contract-design.md | 4 +- ...-external-artifact-publication-contract.md | 2 +- scripts/ci/check-tool-contracts.py | 77 ++++++++++++------- tests/ci/test_product_contracts.py | 30 ++++++++ 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md b/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md index 6023046c9..b4c47bc37 100644 --- a/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md +++ b/docs/design/2026-08-12-windows-external-artifact-publication-contract-design.md @@ -11,8 +11,8 @@ Unica 0.11.0 поставляет `v8-runner` 0.5.1 из commit успешно формирует и проверяет staged EPF/ERF, но затем может завершиться кодом 3 при публикации каталога `output`: старый runner открывает родительский каталог как обычный файл перед `fsync`, получает `ERROR_ACCESS_DENIED` или -`ERROR_PATH_NOT_FOUND` и запускает rollback. Это пользовательский дефект из -#310 и его EPF-воспроизведение #264. +`ERROR_PATH_NOT_FOUND` и запускает rollback. Это пользовательский дефект +из #310, а его EPF-воспроизведение — #264. Корневая причина уже исправлена в `alkoleft/v8-runner-rust#48`: вне Unix directory fsync становится успешной no-op, а ошибки создания, записи и rename diff --git a/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md b/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md index 66d4cb847..7c2b80977 100644 --- a/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md +++ b/docs/plans/2026-08-12-windows-external-artifact-publication-contract.md @@ -279,7 +279,7 @@ if argument.eq_ignore_ascii_case("/Out") { } ``` -Compile the same executable to `platform/bin/1cv8c.exe` and copy it to `platform/bin/1cv8.exe`. The config contains: +Compile the same executable to `platform/bin/1cv8c.exe` and copy it to `platform/bin/1cv8.exe`. Route all three contract-fixture `rustc` calls through one helper with a 60-second timeout and a labeled `TimeoutExpired` diagnostic. The config contains: ```yaml workPath: '' diff --git a/scripts/ci/check-tool-contracts.py b/scripts/ci/check-tool-contracts.py index 2aef0098b..8b03dd73b 100644 --- a/scripts/ci/check-tool-contracts.py +++ b/scripts/ci/check-tool-contracts.py @@ -42,6 +42,33 @@ V8_RUNNER_BOUNDED_OUTPUT_MARKER = "bounded-platform-out" V8_RUNNER_BOUNDED_STDERR_MARKER = "bounded-client-stderr" +V8_RUNNER_STUB_COMPILE_TIMEOUT_SECONDS = 60 + + +def compile_rust_platform_stub( + source: Path, + output: Path, + cwd: Path, + label: str, +) -> list[str]: + try: + compiled = subprocess.run( + ["rustc", "--edition=2021", str(source), "-o", str(output)], + cwd=cwd, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=V8_RUNNER_STUB_COMPILE_TIMEOUT_SECONDS, + check=False, + ) + except subprocess.TimeoutExpired: + return [ + f"{label}: platform stub compilation timed out after " + f"{V8_RUNNER_STUB_COMPILE_TIMEOUT_SECONDS} seconds" + ] + if compiled.returncode != 0: + return [f"{label}: failed to compile platform stub: {compiled.stderr.strip()}"] + return [] def validate_v8_runner_partial_load_list(payload: bytes, expected_path: str) -> list[str]: @@ -115,16 +142,14 @@ def check_v8_runner_partial_load_contract(runner: Path, target: str) -> list[str encoding="utf-8", ) platform = root / ("1cv8.exe" if target == "win-x64" else "1cv8") - compiled = subprocess.run( - ["rustc", "--edition=2021", str(stub_source), "-o", str(platform)], - cwd=root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, + compile_errors = compile_rust_platform_stub( + stub_source, + platform, + root, + label, ) - if compiled.returncode != 0: - return [f"{label}: failed to compile platform stub: {compiled.stderr.strip()}"] + if compile_errors: + return compile_errors def yaml_path(path: Path) -> str: return str(path).replace("'", "''") @@ -470,16 +495,14 @@ def check_v8_runner_bounded_external_epf_contract( suffix = ".exe" if target == "win-x64" else "" client_platform = platform_bin / f"1cv8c{suffix}" gui_platform = platform_bin / f"1cv8{suffix}" - compiled = subprocess.run( - ["rustc", "--edition=2021", str(stub_source), "-o", str(client_platform)], - cwd=root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, + compile_errors = compile_rust_platform_stub( + stub_source, + client_platform, + root, + label, ) - if compiled.returncode != 0: - return [f"{label}: failed to compile platform stub: {compiled.stderr.strip()}"] + if compile_errors: + return compile_errors shutil.copy2(client_platform, gui_platform) def yaml_path(path: Path) -> str: @@ -618,18 +641,14 @@ def check_v8_runner_windows_external_publication_contract( ) client_platform = platform_bin / "1cv8c.exe" gui_platform = platform_bin / "1cv8.exe" - compiled = subprocess.run( - ["rustc", "--edition=2021", str(stub_source), "-o", str(client_platform)], - cwd=root, - text=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - check=False, + compile_errors = compile_rust_platform_stub( + stub_source, + client_platform, + root, + label, ) - if compiled.returncode != 0: - return [ - f"{label}: failed to compile platform stub: {compiled.stderr.strip()}" - ] + if compile_errors: + return compile_errors shutil.copy2(client_platform, gui_platform) def yaml_path(path: Path) -> str: diff --git a/tests/ci/test_product_contracts.py b/tests/ci/test_product_contracts.py index 02c677697..69b04864c 100644 --- a/tests/ci/test_product_contracts.py +++ b/tests/ci/test_product_contracts.py @@ -3,6 +3,7 @@ import importlib.util import json import re +import subprocess import tempfile import tomllib import unittest @@ -69,6 +70,35 @@ def test_v8_runner_partial_load_list_requires_bom_crlf_and_cyrillic_path(self) - ), ) + def test_v8_runner_platform_stub_compilation_timeout_is_bounded(self) -> None: + module = load_contract_module() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + source = root / "platform-stub.rs" + output = root / "platform-stub.exe" + source.write_text("fn main() {}\n", encoding="utf-8") + with patch.object( + module.subprocess, + "run", + side_effect=subprocess.TimeoutExpired(["rustc"], 60), + ) as compile_run: + errors = module.compile_rust_platform_stub( + source, + output, + root, + "v8-runner fixture", + ) + + self.assertEqual( + errors, + [ + "v8-runner fixture: platform stub compilation timed out " + "after 60 seconds" + ], + ) + self.assertEqual(compile_run.call_args.kwargs["timeout"], 60) + def test_v8_runner_partial_load_smoke_rejects_missing_binary(self) -> None: module = load_contract_module()