diff --git a/docs/ci/TOOL_VERSION_MANAGEMENT.md b/docs/ci/TOOL_VERSION_MANAGEMENT.md index 6d90882d7..18010cde7 100644 --- a/docs/ci/TOOL_VERSION_MANAGEMENT.md +++ b/docs/ci/TOOL_VERSION_MANAGEMENT.md @@ -214,6 +214,25 @@ To ensure CI validation and autofix produce identical output, both must use the 2. More mature and stable 3. Explicit formatting rules prevent ambiguity +## Renovate vs Maint 68 File Ownership + +Dev-tool pins are excluded from Renovate (see the fleet preset) because +`autofix-versions.env` owns them. A second, path-level boundary applies to +consumer repos: Maint 68 overwrites every manifest-managed file on each sync, so +a consumer Renovate PR editing one of those files is reverted on the next sync. + +`renovate-presets/consumer-managed-paths.json` is generated from +`.github/sync-manifest.yml` and disables dependency extraction for exactly those +paths, per repo. Renovate stays enabled for `create_only`/`skip_repos` paths the +consumer owns and for every canonical source file in `stranske/Workflows`, which +means action and dependency bumps still land here first and reach consumers +through the sync. + +Regenerate with `python scripts/generate_consumer_renovate_ownership.py` after +changing the manifest; `--check` runs in `scripts/dev_check.sh` and fails on +drift. Full ownership table: +[Consumer Repository Maintenance](../ops/CONSUMER_REPO_MAINTENANCE.md#renovate-vs-maint-68-path-ownership). + ## Related Documentation - [Autofix System](AUTOFIX.md) - How automatic fixes work diff --git a/docs/ops/CONSUMER_REPO_MAINTENANCE.md b/docs/ops/CONSUMER_REPO_MAINTENANCE.md index c2be9bf51..6feb7fc82 100644 --- a/docs/ops/CONSUMER_REPO_MAINTENANCE.md +++ b/docs/ops/CONSUMER_REPO_MAINTENANCE.md @@ -328,6 +328,46 @@ exist, but continues syncing the canonical pins from Workflows. The `maint-auto-update-pypi-versions.yml` workflow owns opening source bump PRs for freshness updates. +### Renovate vs Maint 68 Path Ownership + +Maint 68 overwrites every manifest-managed path in a consumer on each sync. If +that consumer's own Renovate opens a PR touching one of those paths, the change +is discarded on the next sync — consumer Renovate PRs against +`.github/workflows/agents-guard.yml` and +`.github/workflows/maint-76-claude-code-review.yml` (Inv-Man-Intake#838, +Manager-Database#1347) were both closed unmerged for exactly this reason. + +`renovate-presets/consumer-managed-paths.json` encodes the boundary. It is +**generated** from `.github/sync-manifest.yml` and the registered consumer list, +and `renovate-presets/fleet.json` extends it, so every consumer inherits it +without a re-sync. Ownership follows the same rules Maint 68 applies: + +| Manifest state | Owner | Renovate | +| --- | --- | --- | +| No `sync_mode` (overwrite-managed) | Workflows | disabled in consumers | +| `sync_mode: create_only` | consumer, after first seed | enabled | +| `sync_mode: create_only` + repo in `overwrite_repos` | Workflows | disabled in that repo | +| Repo listed in `skip_repos` | consumer | enabled in that repo | + +The preset matches consumer repositories only. `stranske/Workflows` is the sync +source, so its canonical files stay fully Renovate-managed and dependency bumps +still land here first, then reach consumers through Maint 68. + +Note that `.github/workflows/autofix.yml` has no `sync_mode`, which makes it +overwrite-managed and therefore disabled for consumer Renovate. `ci.yml` and +`pr-00-gate.yml` are `create_only` and stay consumer-owned. + +Regenerate after any manifest change: + +```bash +python scripts/generate_consumer_renovate_ownership.py # rewrite the preset +python scripts/generate_consumer_renovate_ownership.py --check # fail on drift +``` + +`scripts/dev_check.sh` runs `--check` (and regenerates under `--fix`), and +`tests/scripts/test_generate_consumer_renovate_ownership.py` fails when a new +overwrite-managed path becomes visible to consumer Renovate. + ### Monorepo Package Dependencies (`app-baseline-kit`) Shared packages that live in this repo under `packages/` (currently diff --git a/renovate-presets/consumer-managed-paths.json b/renovate-presets/consumer-managed-paths.json new file mode 100644 index 000000000..7b1c38265 --- /dev/null +++ b/renovate-presets/consumer-managed-paths.json @@ -0,0 +1,296 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "description": "GENERATED by scripts/generate_consumer_renovate_ownership.py -- do not edit by hand. Disables Renovate dependency extraction for the paths that maint-68-sync-consumer-repos.yml overwrites from .github/sync-manifest.yml, and only in the consumer repos where that overwrite actually applies. Without this boundary a consumer's Renovate opens PRs against centrally-copied files (Inv-Man-Intake#838, Manager-Database#1347) that the next sync silently reverts. Renovate stays enabled for create-only/skipped paths the consumer owns, and for every canonical source file in stranske/Workflows, which is the sync source rather than a consumer. Regenerate with `python scripts/generate_consumer_renovate_ownership.py`; `--check` fails on drift and runs in scripts/dev_check.sh.", + "packageRules": [ + { + "description": "Maint 68 overwrites these 207 manifest-managed paths in every registered consumer; Renovate edits there are reverted on the next sync.", + "matchRepositories": [ + "stranske/Collab-Admin", + "stranske/Counter_Risk", + "stranske/Fine-Art-Archive", + "stranske/Inv-Man-Intake", + "stranske/Manager-Database", + "stranske/Pension-Data", + "stranske/Portable-Alpha-Extension-Model", + "stranske/Ready", + "stranske/Template", + "stranske/Travel-Plan-Permission", + "stranske/Trend_Model_Project", + "stranske/learning-management-system", + "stranske/trip-planner" + ], + "matchFileNames": [ + ".coderabbit.yaml", + ".gitattributes", + ".github/ISSUE_TEMPLATE/agent_task.yml", + ".github/ISSUE_TEMPLATE/config.yml", + ".github/PULL_REQUEST_TEMPLATE.md", + ".github/actions/agent-event-eligibility/**", + ".github/actions/agent-run-base/**", + ".github/actions/artifact-cache/**", + ".github/actions/export-load-balancer-tokens/**", + ".github/actions/path-classifier/**", + ".github/actions/resolve-default-branch/**", + ".github/actions/setup-api-client/**", + ".github/agents/registry.yml", + ".github/codex/AGENT_INSTRUCTIONS.md", + ".github/codex/prompts/autofix_from_ci_failure.md", + ".github/codex/prompts/fix_bot_comments.md", + ".github/codex/prompts/fix_ci_failures.md", + ".github/codex/prompts/fix_merge_conflicts.md", + ".github/codex/prompts/keepalive_next_task.md", + ".github/codex/prompts/verifier_acceptance_check.md", + ".github/copilot-instructions.md", + ".github/copilot-skills/**", + ".github/path-classification.yml", + ".github/scripts/agent_delegation_policy.js", + ".github/scripts/agent_registry.js", + ".github/scripts/agent_stall_rotation.js", + ".github/scripts/agents-guard.js", + ".github/scripts/agents_belt_scan.js", + ".github/scripts/agents_dispatch_summary.js", + ".github/scripts/agents_orchestrator_resolve.js", + ".github/scripts/agents_pr_meta_keepalive.js", + ".github/scripts/agents_pr_meta_orchestrator.js", + ".github/scripts/agents_pr_meta_update_body.js", + ".github/scripts/agents_verifier_context.js", + ".github/scripts/auto_pilot_transitions.js", + ".github/scripts/autofix_emit_report.py", + ".github/scripts/autopilot_metrics.js", + ".github/scripts/bot-comment-dismiss.js", + ".github/scripts/bot-comment-handler.js", + ".github/scripts/bot_comment_auth_coverage.js", + ".github/scripts/capability_bundle.js", + ".github/scripts/checkout_source.js", + ".github/scripts/comment-dedupe.js", + ".github/scripts/conflict_detector.js", + ".github/scripts/coverage-normalize.js", + ".github/scripts/coverage_monitor_summary.js", + ".github/scripts/decode_raw_input.py", + ".github/scripts/detect-changes.js", + ".github/scripts/error_classifier.js", + ".github/scripts/error_diagnostics.js", + ".github/scripts/failure_comment_formatter.js", + ".github/scripts/fallback_split.py", + ".github/scripts/gate-docs-only.js", + ".github/scripts/gate_summary.py", + ".github/scripts/github-api-cache-client.js", + ".github/scripts/github-api-cache.js", + ".github/scripts/github-api-with-retry.js", + ".github/scripts/github-rate-limited-wrapper.js", + ".github/scripts/issue_context_utils.js", + ".github/scripts/issue_pr_locator.js", + ".github/scripts/issue_scope_parser.js", + ".github/scripts/keepalive_contract.js", + ".github/scripts/keepalive_gate.js", + ".github/scripts/keepalive_guard_utils.js", + ".github/scripts/keepalive_instruction_template.js", + ".github/scripts/keepalive_loop.js", + ".github/scripts/keepalive_orchestrator_gate_runner.js", + ".github/scripts/keepalive_post_work.js", + ".github/scripts/keepalive_prompt_composer.js", + ".github/scripts/keepalive_prompt_routing.js", + ".github/scripts/keepalive_review_guard.js", + ".github/scripts/keepalive_state.js", + ".github/scripts/keepalive_worker_gate.js", + ".github/scripts/maint-post-ci.js", + ".github/scripts/merge_manager.js", + ".github/scripts/parse_chatgpt_topics.py", + ".github/scripts/post_completion_comment.js", + ".github/scripts/pr-context-graphql.js", + ".github/scripts/prompt_injection_guard.js", + ".github/scripts/prompt_integrity_guard.js", + ".github/scripts/runtime_ac_merge_guard.js", + ".github/scripts/should-post-review.js", + ".github/scripts/source_context.js", + ".github/scripts/sync_tracker_state/**", + ".github/scripts/terminal_disposition.js", + ".github/scripts/terminal_disposition_coverage.js", + ".github/scripts/timeout_config.js", + ".github/scripts/token_load_balancer.js", + ".github/scripts/verifier_ci_query.js", + ".github/scripts/verifier_issue_formatter.js", + ".github/scripts/verifier_verdict_json.py", + ".github/scripts/weekly_metrics_artifacts.js", + ".github/scripts/weekly_metrics_download_manifest.js", + ".github/templates/keepalive-instruction.md", + ".github/workflows/agents-71-codex-belt-dispatcher.yml", + ".github/workflows/agents-72-codex-belt-worker-dispatch.yml", + ".github/workflows/agents-72-codex-belt-worker.yml", + ".github/workflows/agents-73-codex-belt-conveyor.yml", + ".github/workflows/agents-80-pr-event-hub.yml", + ".github/workflows/agents-81-gate-followups.yml", + ".github/workflows/agents-auto-label.yml", + ".github/workflows/agents-auto-pilot.yml", + ".github/workflows/agents-autofix-dispatcher.yml", + ".github/workflows/agents-capability-check.yml", + ".github/workflows/agents-decompose.yml", + ".github/workflows/agents-dedup.yml", + ".github/workflows/agents-guard.yml", + ".github/workflows/agents-issue-intake.yml", + ".github/workflows/agents-issue-optimizer.yml", + ".github/workflows/agents-keepalive-loop-reporter.yml", + ".github/workflows/agents-keepalive-sweep.yml", + ".github/workflows/agents-pr-health.yml", + ".github/workflows/agents-verifier.yml", + ".github/workflows/agents-verify-to-new-pr.yml", + ".github/workflows/agents-weekly-metrics.yml", + ".github/workflows/autofix.yml", + ".github/workflows/backplane-conformance.yml", + ".github/workflows/list-llm-models.yml", + ".github/workflows/maint-76-claude-code-review.yml", + ".github/workflows/maint-coverage-guard.yml", + ".github/workflows/pr-46-dependency-repair-contract.yml", + ".github/workflows/reusable-pr-context.yml", + "CLAUDE.md", + "WORKFLOW_USER_GUIDE.md", + "config/model_registry.json", + "config/model_selection_policy.json", + "design-system/PRESENTATION_PATTERNS.md", + "design-system/README.md", + "design-system/components.css", + "design-system/ds_streamlit.py", + "design-system/tokens.css", + "docs/AGENT_ISSUE_FORMAT.md", + "docs/CI_SYSTEM_GUIDE.md", + "docs/CODEX_TOKEN_REFRESH.md", + "docs/LABELS.md", + "docs/MODEL_SELECTION_POLICY.md", + "docs/SETUP_CHECKLIST.md", + "docs/contracts/agent-runner-output.md", + "docs/contracts/capability-bundle-v1.md", + "docs/contracts/identity-map-conventions.md", + "docs/contracts/run-contract-v1.md", + "docs/contracts/schemas/artifact-manifest-v1.schema.json", + "docs/contracts/schemas/capability-bundle-v1.schema.json", + "docs/contracts/schemas/evidence-object-v1.schema.json", + "docs/contracts/schemas/run-contract-v1.schema.json", + "scripts/aggregate_agent_metrics.py", + "scripts/api_client.py", + "scripts/autopilot_metrics_collector.py", + "scripts/autopilot_step_timer.py", + "scripts/check_agents_md_freshness.py", + "scripts/check_deliberate_break.py", + "scripts/check_test_dependencies.sh", + "scripts/ci_coverage_delta.py", + "scripts/ci_history.py", + "scripts/ci_metrics.py", + "scripts/coverage_history_append.py", + "scripts/langchain/_llm_client.py", + "scripts/langchain/capability_check.py", + "scripts/langchain/checklist_utils.py", + "scripts/langchain/context_extractor.py", + "scripts/langchain/injection_guard.py", + "scripts/langchain/integration_layer.py", + "scripts/langchain/issue_dedup.py", + "scripts/langchain/issue_formatter.py", + "scripts/langchain/issue_optimizer.py", + "scripts/langchain/issue_pr_context.py", + "scripts/langchain/label_matcher.py", + "scripts/langchain/pr_verifier.py", + "scripts/langchain/progress_reviewer.py", + "scripts/langchain/prompts/analyze_issue.md", + "scripts/langchain/prompts/apply_suggestions.md", + "scripts/langchain/prompts/context_extract.md", + "scripts/langchain/prompts/decompose_task.md", + "scripts/langchain/prompts/format_issue.md", + "scripts/langchain/prompts/pr_evaluation.md", + "scripts/langchain/prompts/refine_tasks.md", + "scripts/langchain/semantic_matcher.py", + "scripts/langchain/structured_output.py", + "scripts/langchain/task_decomposer.py", + "scripts/langchain/task_validator.py", + "scripts/langchain/topic_splitter.py", + "scripts/langchain/trace_utils.py", + "scripts/langchain/verdict_extract.py", + "scripts/langchain/verdict_policy.py", + "scripts/langchain/verifier_config.py", + "scripts/orchestrator_skill.py", + "scripts/reference_packs.py", + "scripts/reusable_ci_scope.py", + "scripts/runner_lib/**", + "scripts/state_fingerprint.py", + "scripts/sync_dev_dependencies.py", + "scripts/sync_test_dependencies.py", + "scripts/validate_run_contract.py", + "tools/__init__.py", + "tools/check_model_registry_freshness.py", + "tools/ci_failure_triage.py", + "tools/coverage_guard.py", + "tools/coverage_trend.py", + "tools/discover_model_catalog.py", + "tools/embedding_provider.py", + "tools/evaluate_model_benchmark.py", + "tools/langchain_client.py", + "tools/llm_provider.py", + "tools/llm_registry.py", + "tools/post_ci_summary.py", + "tools/requirements-llm.txt", + "tools/resolve_mypy_pin.py" + ], + "enabled": false + }, + { + "description": "stranske/Collab-Admin, stranske/Counter_Risk, stranske/Fine-Art-Archive, stranske/Inv-Man-Intake, stranske/Manager-Database, stranske/Pension-Data, stranske/Portable-Alpha-Extension-Model, stranske/Ready, stranske/Travel-Plan-Permission, stranske/learning-management-system additionally has 5 manifest-managed path(s) that at least one other consumer owns via skip_repos, create_only, or an overwrite_repos opt-in.", + "matchRepositories": [ + "stranske/Collab-Admin", + "stranske/Counter_Risk", + "stranske/Fine-Art-Archive", + "stranske/Inv-Man-Intake", + "stranske/Manager-Database", + "stranske/Pension-Data", + "stranske/Portable-Alpha-Extension-Model", + "stranske/Ready", + "stranske/Travel-Plan-Permission", + "stranske/learning-management-system" + ], + "matchFileNames": [ + ".github/scripts/node_modules/balanced-match/**", + ".github/scripts/node_modules/brace-expansion/**", + ".github/scripts/node_modules/minimatch/**", + ".github/scripts/package.json", + "AGENTS.md" + ], + "enabled": false + }, + { + "description": "stranske/Template additionally has 7 manifest-managed path(s) that at least one other consumer owns via skip_repos, create_only, or an overwrite_repos opt-in.", + "matchRepositories": [ + "stranske/Template" + ], + "matchFileNames": [ + ".github/scripts/node_modules/balanced-match/**", + ".github/scripts/node_modules/brace-expansion/**", + ".github/scripts/node_modules/minimatch/**", + ".github/scripts/package.json", + ".github/workflows/ci.yml", + ".github/workflows/pr-00-gate.yml", + "AGENTS.md" + ], + "enabled": false + }, + { + "description": "stranske/Trend_Model_Project additionally has 4 manifest-managed path(s) that at least one other consumer owns via skip_repos, create_only, or an overwrite_repos opt-in.", + "matchRepositories": [ + "stranske/Trend_Model_Project" + ], + "matchFileNames": [ + ".github/scripts/node_modules/balanced-match/**", + ".github/scripts/node_modules/brace-expansion/**", + ".github/scripts/node_modules/minimatch/**", + ".github/scripts/package.json" + ], + "enabled": false + }, + { + "description": "stranske/trip-planner additionally has 1 manifest-managed path(s) that at least one other consumer owns via skip_repos, create_only, or an overwrite_repos opt-in.", + "matchRepositories": [ + "stranske/trip-planner" + ], + "matchFileNames": [ + "AGENTS.md" + ], + "enabled": false + } + ] +} diff --git a/renovate-presets/fleet.json b/renovate-presets/fleet.json index a43e3eb58..bf9718ce4 100644 --- a/renovate-presets/fleet.json +++ b/renovate-presets/fleet.json @@ -1,7 +1,10 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "description": "stranske fleet-shared Renovate preset — single source of truth for dependency automation across all stranske/* repos. Replaces Dependabot for runtime pip deps + GitHub Actions, grouped with automerge-on-green. Dev-tool pins (ruff/black/mypy/pytest/...) are EXCLUDED: they move through autofix-versions.env via the maint workflows, NOT the bumper (mirrors the retired Dependabot ignore list). The vendored .github/scripts npm cascade (minimatch/brace-expansion/balanced-match) is kept in one PR. The pip-compile manager is enabled against requirements.lock so that uv-compiled lock (a `uv pip compile` output) is regenerated in-PR whenever a source dep changes — this is what kept consumers' locks fresh under Dependabot's native lock update; consumers have NO auto-lock workflow and rely on this preset. Each repo's renovate.json extends this via `github>stranske/Workflows//renovate-presets/fleet`, so a fleet-wide policy change is one edit here — no per-repo re-sync.", - "extends": ["config:recommended"], + "description": "stranske fleet-shared Renovate preset — single source of truth for dependency automation across all stranske/* repos. Replaces Dependabot for runtime pip deps + GitHub Actions, grouped with automerge-on-green. Dev-tool pins (ruff/black/mypy/pytest/...) are EXCLUDED: they move through autofix-versions.env via the maint workflows, NOT the bumper (mirrors the retired Dependabot ignore list). The vendored .github/scripts npm cascade (minimatch/brace-expansion/balanced-match) is kept in one PR. The pip-compile manager is enabled against requirements.lock so that uv-compiled lock (a `uv pip compile` output) is regenerated in-PR whenever a source dep changes — this is what kept consumers' locks fresh under Dependabot's native lock update; consumers have NO auto-lock workflow and rely on this preset. Each repo's renovate.json extends this via `github>stranske/Workflows//renovate-presets/fleet`, so a fleet-wide policy change is one edit here — no per-repo re-sync. The extended consumer-managed-paths preset is GENERATED from .github/sync-manifest.yml by scripts/generate_consumer_renovate_ownership.py: it disables dependency extraction for the paths Maint 68 overwrites, per consumer repo, so Renovate stops opening PRs the next sync reverts. It matches consumer repos only, so this repo's canonical sources stay fully Renovate-managed.", + "extends": [ + "config:recommended", + "github>stranske/Workflows//renovate-presets/consumer-managed-paths" + ], "platformAutomerge": true, "labels": ["dependencies"], "timezone": "America/Chicago", diff --git a/scripts/check_deliberate_break.py b/scripts/check_deliberate_break.py index 27ff39d6e..093a01ab0 100644 --- a/scripts/check_deliberate_break.py +++ b/scripts/check_deliberate_break.py @@ -32,6 +32,7 @@ r"\b(assert|expect\(|pytest\.raises\(|assert\.)\b", ) DEFAULT_TIMEOUT_SECONDS = 120 +PYTEST_RUNTIME_DEPENDENCIES = ("pyyaml==6.0.3",) @dataclass(frozen=True) @@ -130,6 +131,33 @@ def _pytest_command(test_id: str) -> tuple[str, ...]: return (sys.executable, "-m", "pytest", test_id, "-q") +def _ensure_pytest_runtime_deps() -> None: + """Install lightweight deps Gate test-quality may not preinstall. + + Gate's test-quality job installs only ``pytest``. Deliberate-break may still + collect tests that import PyYAML (for example via ``sync_manifest_compiler``). + Installing here avoids editing ``pr-00-gate.yml``, which forces an + Actions ``action_required`` approval wait on workflow-touching PRs. + """ + try: + import yaml # noqa: F401 + except ImportError: + subprocess.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + *PYTEST_RUNTIME_DEPENDENCIES, + ], + check=True, + text=True, + capture_output=True, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + + def _run( command: tuple[str, ...], cwd: Path, @@ -244,6 +272,37 @@ def verify_spec( changed_assertions=tampered, ) + except subprocess.TimeoutExpired as exc: + return _json_result( + VERDICT_BROKEN, + reason="command-timeout", + command=list(exc.cmd) if isinstance(exc.cmd, (tuple, list)) else str(exc.cmd), + timeout=exc.timeout, + ) + + try: + _ensure_pytest_runtime_deps() + except subprocess.TimeoutExpired as exc: + return _json_result( + VERDICT_BROKEN, + reason="command-timeout", + command=list(exc.cmd) if isinstance(exc.cmd, (tuple, list)) else str(exc.cmd), + timeout=exc.timeout, + ) + except subprocess.CalledProcessError as exc: + return _json_result( + VERDICT_BROKEN, + reason="dependency-install-failed", + detail=exc.stderr or str(exc), + ) + except OSError as exc: + return _json_result( + VERDICT_BROKEN, + reason="dependency-install-unavailable", + detail=str(exc), + ) + + try: head_run = _run(spec.command, repo) except subprocess.TimeoutExpired as exc: return _json_result( diff --git a/scripts/dev_check.sh b/scripts/dev_check.sh index e95f0c4ba..cca6ba5bb 100755 --- a/scripts/dev_check.sh +++ b/scripts/dev_check.sh @@ -137,6 +137,18 @@ if [[ "$DEV_CHECK_ACTIONLINT_ONLY" != true ]]; then exit 1 fi fi + + # A stale ownership preset lets consumer Renovate edit Maint 68-owned paths. + if ! python scripts/generate_consumer_renovate_ownership.py --check >/dev/null 2>&1; then + if [[ "$FIX_MODE" == true ]]; then + echo -e "${YELLOW}Regenerating renovate-presets/consumer-managed-paths.json${NC}" + python scripts/generate_consumer_renovate_ownership.py >/dev/null + else + echo -e "${RED}✗ renovate-presets/consumer-managed-paths.json is stale. Run 'python scripts/generate_consumer_renovate_ownership.py' or re-run with --fix.${NC}" >&2 + python scripts/generate_consumer_renovate_ownership.py --check + exit 1 + fi + fi fi # Guarantee the Python scripts directory (where flake8 entry point lives) is on PATH. diff --git a/scripts/generate_consumer_renovate_ownership.py b/scripts/generate_consumer_renovate_ownership.py new file mode 100644 index 000000000..6839aa6f6 --- /dev/null +++ b/scripts/generate_consumer_renovate_ownership.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Generate the consumer Renovate ownership preset from the sync manifest. + +Maint 68 overwrites manifest-managed paths in every registered consumer repo on +each sync. When a consumer's own Renovate opens a PR that edits one of those +paths, the change is discarded on the next sync (for example Inv-Man-Intake#838 +and Manager-Database#1347, both closed unmerged). This script compiles the +overwrite-managed target paths out of ``.github/sync-manifest.yml`` and emits a +Renovate preset that disables dependency extraction for exactly those paths in +exactly the repos where Maint 68 owns them. + +Ownership follows the same rules maint-68-sync-consumer-repos.yml applies: + +* ``skip_repos`` means the file is never delivered there, so the consumer owns + any local copy and Renovate stays enabled. +* ``sync_mode: create_only`` means the file is seeded once and then owned by the + consumer, so Renovate stays enabled -- unless the repo is listed in + ``overwrite_repos``, which opts it back into byte-alignment with the template. +* Every other entry is overwrite-managed and must be invisible to consumer + Renovate. + +``stranske/Workflows`` is the sync source rather than a consumer, so it never +appears in ``matchRepositories`` and its canonical source files stay fully +Renovate-managed. + +Usage: + python scripts/generate_consumer_renovate_ownership.py # write preset + python scripts/generate_consumer_renovate_ownership.py --check # fail on drift +""" + +from __future__ import annotations + +import argparse +import json +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from scripts.list_registered_consumer_repos import extract_repos # noqa: E402 +from scripts.sync_manifest_compiler import ( # noqa: E402 + ManifestCompileError, + ManifestEntry, + compile_manifest, +) + +DEFAULT_MANIFEST = Path(".github/sync-manifest.yml") +DEFAULT_SYNC_WORKFLOW = Path(".github/workflows/maint-68-sync-consumer-repos.yml") +DEFAULT_OUTPUT = Path("renovate-presets/consumer-managed-paths.json") +SOURCE_REPO = "stranske/Workflows" + +PRESET_DESCRIPTION = ( + "GENERATED by scripts/generate_consumer_renovate_ownership.py -- do not edit by hand. " + "Disables Renovate dependency extraction for the paths that " + "maint-68-sync-consumer-repos.yml overwrites from .github/sync-manifest.yml, and only in " + "the consumer repos where that overwrite actually applies. Without this boundary a " + "consumer's Renovate opens PRs against centrally-copied files (Inv-Man-Intake#838, " + "Manager-Database#1347) that the next sync silently reverts. Renovate stays enabled for " + "create-only/skipped paths the consumer owns, and for every canonical source file in " + f"{SOURCE_REPO}, which is the sync source rather than a consumer. Regenerate with " + "`python scripts/generate_consumer_renovate_ownership.py`; `--check` fails on drift and " + "runs in scripts/dev_check.sh." +) + + +def is_overwrite_managed(entry: ManifestEntry, repo: str) -> bool: + """Return whether Maint 68 overwrites ``entry``'s target in ``repo``.""" + if repo in {rule.repo for rule in entry.skip_repos}: + return False + return not (entry.sync_mode == "create_only" and repo not in entry.overwrite_repos) + + +def match_pattern(entry: ManifestEntry) -> str: + """Return the Renovate ``matchFileNames`` glob for ``entry``'s target.""" + return f"{entry.target}/**" if entry.is_directory else entry.target + + +def managed_paths_by_repo( + entries: list[ManifestEntry], consumers: list[str] +) -> dict[str, list[str]]: + """Map each consumer repo to the manifest-managed paths Renovate must skip.""" + by_repo: dict[str, list[str]] = {} + for repo in consumers: + if repo == SOURCE_REPO: + continue + managed = sorted( + {match_pattern(entry) for entry in entries if is_overwrite_managed(entry, repo)} + ) + if managed: + by_repo[repo] = managed + return by_repo + + +def build_package_rules(entries: list[ManifestEntry], consumers: list[str]) -> list[dict[str, Any]]: + """Emit a shared base rule plus per-repo deltas. + + Every rule disables, so the rules are purely additive: a repo's effective + disabled set is the union of the rules that match it. That keeps the preset + a fraction of the size of one full path list per repo without needing an + ``enabled: true`` override, which would be order-sensitive. + """ + by_repo = managed_paths_by_repo(entries, consumers) + if not by_repo: + return [] + + shared = sorted(set.intersection(*(set(paths) for paths in by_repo.values()))) + rules: list[dict[str, Any]] = [] + if shared: + rules.append( + { + "description": ( + f"Maint 68 overwrites these {len(shared)} manifest-managed paths in every " + "registered consumer; Renovate edits there are reverted on the next sync." + ), + "matchRepositories": sorted(by_repo), + "matchFileNames": shared, + "enabled": False, + } + ) + + shared_set = set(shared) + grouped: dict[tuple[str, ...], list[str]] = defaultdict(list) + for repo, paths in by_repo.items(): + grouped[tuple(paths)].append(repo) + + for paths, repos in sorted(grouped.items(), key=lambda item: sorted(item[1])): + extra = sorted(set(paths) - shared_set) + if not extra: + continue + rules.append( + { + "description": ( + f"{', '.join(sorted(repos))} additionally has {len(extra)} manifest-managed " + "path(s) that at least one other consumer owns via skip_repos, create_only, " + "or an overwrite_repos opt-in." + ), + "matchRepositories": sorted(repos), + "matchFileNames": extra, + "enabled": False, + } + ) + return rules + + +def build_preset( + *, + manifest: Path, + sync_workflow: Path, + repo_root: Path, +) -> dict[str, Any]: + compiled = compile_manifest(manifest, repo_root=repo_root) + consumers = extract_repos(sync_workflow) + if not consumers: + raise ValueError(f"No registered consumer repos found in {sync_workflow}") + return { + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "description": PRESET_DESCRIPTION, + "packageRules": build_package_rules(compiled.all_entries(), consumers), + } + + +def render(preset: dict[str, Any]) -> str: + return json.dumps(preset, indent=2, ensure_ascii=False) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST) + parser.add_argument("--sync-workflow", type=Path, default=DEFAULT_SYNC_WORKFLOW) + parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) + parser.add_argument( + "--repo-root", + type=Path, + default=None, + help="Repository root used to resolve manifest sources (default: manifest's repo)", + ) + parser.add_argument( + "--check", + action="store_true", + help="Exit non-zero when the committed preset differs from the generated one", + ) + args = parser.parse_args(argv) + + repo_root = ( + args.repo_root if args.repo_root is not None else args.manifest.resolve().parent.parent + ) + try: + rendered = render( + build_preset( + manifest=args.manifest, + sync_workflow=args.sync_workflow, + repo_root=repo_root, + ) + ) + except (OSError, ManifestCompileError, ValueError) as exc: + print(f"::error::{exc}", file=sys.stderr) + return 1 + + if args.check: + current = args.output.read_text(encoding="utf-8") if args.output.is_file() else "" + if current != rendered: + print( + f"{args.output} is stale. Run " + "`python scripts/generate_consumer_renovate_ownership.py` and commit the result.", + file=sys.stderr, + ) + return 1 + print(f"{args.output} is up to date.") + return 0 + + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(rendered, encoding="utf-8") + print(f"Wrote {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/scripts/test_check_deliberate_break.py b/tests/scripts/test_check_deliberate_break.py index 1b1120c4d..857ab8828 100644 --- a/tests/scripts/test_check_deliberate_break.py +++ b/tests/scripts/test_check_deliberate_break.py @@ -1,9 +1,13 @@ +import builtins import os import subprocess import sys from pathlib import Path +import pytest +import scripts.check_deliberate_break as deliberate_break from scripts.check_deliberate_break import ( + PYTEST_RUNTIME_DEPENDENCIES, VERDICT_BROKEN, VERDICT_HOLLOW, VERDICT_PASS, @@ -217,3 +221,108 @@ def test_cli_skips_without_marker(tmp_path) -> None: assert completed.returncode == 0 assert "skipped: no deliberate-break marker" in completed.stdout + + +def test_runtime_dependency_installer_uses_locked_pyyaml(monkeypatch) -> None: + real_import = builtins.__import__ + calls: list[tuple[object, dict[str, object]]] = [] + + def missing_yaml(name, *args, **kwargs): + if name == "yaml": + raise ImportError("PyYAML missing") + return real_import(name, *args, **kwargs) + + def record_install(*args, **kwargs): + calls.append((args, kwargs)) + return subprocess.CompletedProcess(args[0], 0, "", "") + + monkeypatch.setattr(builtins, "__import__", missing_yaml) + monkeypatch.setattr(deliberate_break.subprocess, "run", record_install) + + deliberate_break._ensure_pytest_runtime_deps() + + assert calls == [ + ( + ( + [ + sys.executable, + "-m", + "pip", + "install", + "--upgrade", + *PYTEST_RUNTIME_DEPENDENCIES, + ], + ), + { + "check": True, + "text": True, + "capture_output": True, + "timeout": deliberate_break.DEFAULT_TIMEOUT_SECONDS, + }, + ) + ] + + +def _sound_spec(repo: Path) -> tuple[str, object]: + _write_app(repo, 0) + base = _commit(repo, "base behavior") + _write_app(repo, 1) + _write_test(repo, 1) + _commit(repo, "implementation and test") + spec = parse_deliberate_break_spec( + "" + ) + assert spec is not None + return base, spec + + +def test_dependency_install_timeout_is_broken(tmp_path, monkeypatch) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _init_repo(repo) + base, spec = _sound_spec(repo) + command = [sys.executable, "-m", "pip", "install", *PYTEST_RUNTIME_DEPENDENCIES] + + def timed_out() -> None: + raise subprocess.TimeoutExpired(command, 17) + + monkeypatch.setattr(deliberate_break, "_ensure_pytest_runtime_deps", timed_out) + + result = verify_spec(spec, base=base, cwd=repo, enforce_tamper=False) + + assert result == { + "verdict": VERDICT_BROKEN, + "reason": "command-timeout", + "command": command, + "timeout": 17, + } + + +@pytest.mark.parametrize( + ("error", "reason", "detail"), + [ + ( + subprocess.CalledProcessError(1, ["pip"], stderr="pip denied"), + "dependency-install-failed", + "pip denied", + ), + (OSError("pip unavailable"), "dependency-install-unavailable", "pip unavailable"), + ], +) +def test_dependency_install_errors_are_broken(tmp_path, monkeypatch, error, reason, detail) -> None: + repo = tmp_path / "repo" + repo.mkdir() + _init_repo(repo) + base, spec = _sound_spec(repo) + + def failed() -> None: + raise error + + monkeypatch.setattr(deliberate_break, "_ensure_pytest_runtime_deps", failed) + + result = verify_spec(spec, base=base, cwd=repo, enforce_tamper=False) + + assert result == {"verdict": VERDICT_BROKEN, "reason": reason, "detail": detail} diff --git a/tests/scripts/test_generate_consumer_renovate_ownership.py b/tests/scripts/test_generate_consumer_renovate_ownership.py new file mode 100644 index 000000000..fedf317d6 --- /dev/null +++ b/tests/scripts/test_generate_consumer_renovate_ownership.py @@ -0,0 +1,316 @@ +"""Contract tests for the generated consumer Renovate ownership preset. + +The preset must stay derived from the live `.github/sync-manifest.yml`: if a new +overwrite-managed path appears there and the preset is not regenerated, consumer +Renovate starts opening PRs that the next Maint 68 sync silently reverts. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +import yaml +from scripts.generate_consumer_renovate_ownership import ( + SOURCE_REPO, + build_preset, + is_overwrite_managed, + main, + match_pattern, + render, +) +from scripts.list_registered_consumer_repos import extract_repos +from scripts.sync_manifest_compiler import compile_manifest + +ROOT = Path(__file__).parents[2] +MANIFEST = ROOT / ".github" / "sync-manifest.yml" +SYNC_WORKFLOW = ROOT / ".github" / "workflows" / "maint-68-sync-consumer-repos.yml" +PRESET = ROOT / "renovate-presets" / "consumer-managed-paths.json" +FLEET_PRESET = ROOT / "renovate-presets" / "fleet.json" + +# The two paths from the issue's cited incident: consumer Renovate opened PRs +# against them (Inv-Man-Intake#838, Manager-Database#1347) and both were closed +# unmerged because Maint 68 owns the files. +INCIDENT_PATHS = ( + ".github/workflows/agents-guard.yml", + ".github/workflows/maint-76-claude-code-review.yml", +) + + +def committed_preset() -> dict: + return json.loads(PRESET.read_text(encoding="utf-8")) + + +def disabled_paths(preset: dict, repo: str) -> set[str]: + """Return the effective disabled-path set for one repo. + + Every rule in the preset disables, so the rules are additive and a repo's + effective set is the union of the rules whose matchRepositories include it. + """ + paths: set[str] = set() + for rule in preset["packageRules"]: + assert rule["enabled"] is False, "every generated rule must disable, never re-enable" + if repo in rule["matchRepositories"]: + paths.update(rule["matchFileNames"]) + return paths + + +def registered_consumers() -> list[str]: + return extract_repos(SYNC_WORKFLOW) + + +def expected_managed(repo: str) -> set[str]: + entries = compile_manifest(MANIFEST, repo_root=ROOT).all_entries() + return {match_pattern(e) for e in entries if is_overwrite_managed(e, repo)} + + +def write_manifest(tmp_path: Path, manifest: dict) -> Path: + """Materialise a synthetic manifest plus the source files it references.""" + (tmp_path / ".github").mkdir(parents=True, exist_ok=True) + for section, entries in manifest.items(): + if section == "version": + continue + for entry in entries: + source = tmp_path / "templates" / "consumer-repo" / entry["source"] + if entry.get("is_directory"): + source.mkdir(parents=True, exist_ok=True) + (source / "index.js").write_text("// fixture\n", encoding="utf-8") + else: + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("# fixture\n", encoding="utf-8") + path = tmp_path / ".github" / "sync-manifest.yml" + path.write_text(yaml.safe_dump(manifest, sort_keys=False), encoding="utf-8") + return path + + +def write_sync_workflow(tmp_path: Path, repos: list[str]) -> Path: + path = tmp_path / ".github" / "workflows" / "maint-68-sync-consumer-repos.yml" + path.parent.mkdir(parents=True, exist_ok=True) + listing = "\n".join(f" {repo}" for repo in repos) + path.write_text(f"env:\n REGISTERED_CONSUMER_REPOS: |\n{listing}\n", encoding="utf-8") + return path + + +def synthetic_preset(tmp_path: Path, manifest: dict, repos: list[str]) -> dict: + return build_preset( + manifest=write_manifest(tmp_path, manifest), + sync_workflow=write_sync_workflow(tmp_path, repos), + repo_root=tmp_path, + ) + + +# -------------------------------------------------------------------------- +# Live-manifest contract +# -------------------------------------------------------------------------- + + +def test_manifest_managed_workflows_are_disabled_for_consumers(): + """Deliberate-break gate for issue #2876. + + Remove the agents-guard.yml entry from .github/sync-manifest.yml (or hand-edit + the generated rule) and this test must fail. + """ + preset = committed_preset() + consumers = [repo for repo in registered_consumers() if repo != SOURCE_REPO] + assert consumers, "no registered consumer repos parsed from maint-68" + + for repo in consumers: + effective = disabled_paths(preset, repo) + for path in INCIDENT_PATHS: + assert path in effective, f"{path} must be invisible to Renovate in {repo}" + assert effective == expected_managed(repo), ( + f"{repo}: preset disagrees with the live manifest. Run " + "`python scripts/generate_consumer_renovate_ownership.py`." + ) + + +def test_create_only_targets_stay_visible_to_consumer_renovate(): + """`.github/workflows/ci.yml` is create_only, so consumers own their copy.""" + preset = committed_preset() + consumers = [ + repo for repo in registered_consumers() if repo not in {SOURCE_REPO, "stranske/Template"} + ] + + for repo in consumers: + effective = disabled_paths(preset, repo) + assert ".github/workflows/ci.yml" not in effective + assert ".github/workflows/pr-00-gate.yml" not in effective + assert ".github/renovate.json" not in effective + + +def test_overwrite_repos_opt_back_into_managed_paths(): + """stranske/Template is in ci.yml's overwrite_repos, so it is managed there.""" + effective = disabled_paths(committed_preset(), "stranske/Template") + assert ".github/workflows/ci.yml" in effective + assert ".github/workflows/pr-00-gate.yml" in effective + + +def test_skip_repos_keep_the_path_consumer_owned(): + """AGENTS.md is skipped for Trend_Model_Project but managed elsewhere.""" + preset = committed_preset() + assert "AGENTS.md" not in disabled_paths(preset, "stranske/Trend_Model_Project") + assert "AGENTS.md" in disabled_paths(preset, "stranske/Inv-Man-Intake") + + # trip-planner skips the vendored npm cascade; everyone else receives it. + assert ".github/scripts/package.json" not in disabled_paths(preset, "stranske/trip-planner") + assert ".github/scripts/package.json" in disabled_paths(preset, "stranske/Inv-Man-Intake") + + +def test_directory_entries_use_a_recursive_glob(): + effective = disabled_paths(committed_preset(), "stranske/Inv-Man-Intake") + assert ".github/scripts/node_modules/minimatch/**" in effective + assert "scripts/runner_lib/**" in effective + + +def test_source_repository_is_never_matched(): + """Workflows is the sync source; its canonical files stay Renovate-managed.""" + preset = committed_preset() + for rule in preset["packageRules"]: + assert SOURCE_REPO not in rule["matchRepositories"] + assert disabled_paths(preset, SOURCE_REPO) == set() + + +def test_preset_does_not_blanket_ignore_workflows(): + """Non-goal: never disable all of `.github/workflows/**`.""" + for rule in committed_preset()["packageRules"]: + for pattern in rule["matchFileNames"]: + assert pattern not in {".github/workflows/**", ".github/**", "**"} + + +def test_fleet_preset_extends_the_generated_ownership_preset(): + fleet = json.loads(FLEET_PRESET.read_text(encoding="utf-8")) + assert "github>stranske/Workflows//renovate-presets/consumer-managed-paths" in fleet["extends"] + + +def test_committed_preset_is_not_stale(): + expected = render(build_preset(manifest=MANIFEST, sync_workflow=SYNC_WORKFLOW, repo_root=ROOT)) + assert PRESET.read_text(encoding="utf-8") == expected + + +def test_check_mode_reports_drift(tmp_path, monkeypatch, capsys): + monkeypatch.chdir(ROOT) + assert main(["--check"]) == 0 + + stale = tmp_path / "stale.json" + stale.write_text('{"packageRules": []}\n', encoding="utf-8") + assert main(["--check", "--output", str(stale)]) == 1 + assert "is stale" in capsys.readouterr().err + + +def test_generator_writes_the_output_file(tmp_path, monkeypatch): + monkeypatch.chdir(ROOT) + out = tmp_path / "nested" / "preset.json" + assert main(["--output", str(out)]) == 0 + assert json.loads(out.read_text(encoding="utf-8"))["packageRules"] + + +# -------------------------------------------------------------------------- +# Synthetic-manifest unit coverage +# -------------------------------------------------------------------------- + + +@pytest.fixture +def repos() -> list[str]: + return ["stranske/Alpha", "stranske/Beta"] + + +def test_exact_file_entry_is_managed_everywhere(tmp_path, repos): + manifest = { + "version": 1, + "workflows": [{"source": ".github/workflows/managed.yml", "description": "managed"}], + } + preset = synthetic_preset(tmp_path, manifest, repos) + for repo in repos: + assert disabled_paths(preset, repo) == {".github/workflows/managed.yml"} + + +def test_directory_entry_expands_to_recursive_glob(tmp_path, repos): + manifest = { + "version": 1, + "scripts": [ + { + "source": ".github/scripts/vendored", + "description": "vendored directory", + "is_directory": True, + } + ], + } + preset = synthetic_preset(tmp_path, manifest, repos) + assert disabled_paths(preset, "stranske/Alpha") == {".github/scripts/vendored/**"} + + +def test_create_only_entry_is_not_managed(tmp_path, repos): + manifest = { + "version": 1, + "workflows": [ + { + "source": ".github/workflows/seeded.yml", + "description": "seeded once", + "sync_mode": "create_only", + } + ], + } + preset = synthetic_preset(tmp_path, manifest, repos) + assert preset["packageRules"] == [] + + +def test_overwrite_repos_reinstates_create_only_management(tmp_path, repos): + manifest = { + "version": 1, + "workflows": [ + { + "source": ".github/workflows/seeded.yml", + "description": "seeded once", + "sync_mode": "create_only", + "overwrite_repos": ["stranske/Beta"], + } + ], + } + preset = synthetic_preset(tmp_path, manifest, repos) + assert disabled_paths(preset, "stranske/Alpha") == set() + assert disabled_paths(preset, "stranske/Beta") == {".github/workflows/seeded.yml"} + + +def test_skip_repos_excludes_only_the_named_repo(tmp_path, repos): + manifest = { + "version": 1, + "workflows": [ + { + "source": ".github/workflows/managed.yml", + "description": "managed", + "skip_repos": [{"repo": "stranske/Alpha", "reason": "custom"}], + } + ], + } + preset = synthetic_preset(tmp_path, manifest, repos) + assert disabled_paths(preset, "stranske/Alpha") == set() + assert disabled_paths(preset, "stranske/Beta") == {".github/workflows/managed.yml"} + + +def test_target_override_is_used_instead_of_source(tmp_path, repos): + manifest = { + "version": 1, + "scripts": [ + { + "source": "scripts/helper.py", + "target": "tools/helper.py", + "description": "retargeted", + } + ], + } + preset = synthetic_preset(tmp_path, manifest, repos) + assert disabled_paths(preset, "stranske/Alpha") == {"tools/helper.py"} + + +def test_generation_is_deterministic(tmp_path, repos): + manifest = { + "version": 1, + "workflows": [ + {"source": ".github/workflows/b.yml", "description": "b"}, + {"source": ".github/workflows/a.yml", "description": "a"}, + ], + } + first = render(synthetic_preset(tmp_path / "one", manifest, repos)) + second = render(synthetic_preset(tmp_path / "two", manifest, list(reversed(repos)))) + assert first == second diff --git a/tests/workflows/test_dependency_bot_conditions.py b/tests/workflows/test_dependency_bot_conditions.py index 2b798f632..1075589de 100644 --- a/tests/workflows/test_dependency_bot_conditions.py +++ b/tests/workflows/test_dependency_bot_conditions.py @@ -1,9 +1,13 @@ +import json import pathlib import yaml +from scripts.list_registered_consumer_repos import extract_repos WORKFLOW_ROOT = pathlib.Path(".github/workflows") SYNC_MANIFEST = pathlib.Path(".github/sync-manifest.yml") +SYNC_WORKFLOW = WORKFLOW_ROOT / "maint-68-sync-consumer-repos.yml" +OWNERSHIP_PRESET = pathlib.Path("renovate-presets/consumer-managed-paths.json") ACTIVE_DEPENDENCY_BOT_WORKFLOWS = ( WORKFLOW_ROOT / "maint-auto-label-dep-prs.yml", WORKFLOW_ROOT / "maint-auto-lock-deps.yml", @@ -13,6 +17,15 @@ ) +def _renovate_disabled_paths(repo: str) -> set[str]: + preset = json.loads(OWNERSHIP_PRESET.read_text(encoding="utf-8")) + paths: set[str] = set() + for rule in preset["packageRules"]: + if repo in rule["matchRepositories"]: + paths.update(rule["matchFileNames"]) + return paths + + def _workflow_source(path: pathlib.Path) -> str: assert path.exists(), f"Expected workflow file to exist: {path}" return path.read_text(encoding="utf-8") @@ -38,3 +51,51 @@ def test_retired_consumer_dependabot_automerge_template_stays_removed(): manifest = yaml.safe_load(SYNC_MANIFEST.read_text(encoding="utf-8")) or {} removal_targets = {entry.get("target") for entry in manifest.get("removals", [])} assert ".github/workflows/dependabot-automerge.yml" in removal_targets + + +def test_consumer_renovate_cannot_edit_maint68_owned_workflows(): + """Consumer Renovate must not touch workflow files Maint 68 overwrites. + + Both paths below produced consumer Renovate PRs (Inv-Man-Intake#838, + Manager-Database#1347) that were closed unmerged because the next sync + reverts them. + """ + consumers = [repo for repo in extract_repos(SYNC_WORKFLOW) if repo != "stranske/Workflows"] + assert consumers + + for repo in consumers: + disabled = _renovate_disabled_paths(repo) + assert ".github/workflows/agents-guard.yml" in disabled + assert ".github/workflows/maint-76-claude-code-review.yml" in disabled + + +def test_consumer_owned_ci_workflow_stays_renovate_eligible(): + """`ci.yml` is `sync_mode: create_only`, so each consumer owns its copy. + + `autofix.yml` is deliberately absent from this assertion: the live manifest + (`.github/sync-manifest.yml`) declares it with no `sync_mode`, which makes it + overwrite-managed in every consumer, and the template ships pinned + `actions/checkout` / `actions/github-script` SHAs that a consumer Renovate + would bump and Maint 68 would then revert. Adding a hand-written exemption + for it would stop the preset being manifest-derived. It stays Renovate- + eligible in this repo, which is asserted below. + """ + consumers = [ + repo + for repo in extract_repos(SYNC_WORKFLOW) + if repo not in {"stranske/Workflows", "stranske/Template"} + ] + assert consumers + + for repo in consumers: + assert ".github/workflows/ci.yml" not in _renovate_disabled_paths(repo) + + +def test_workflows_repo_keeps_full_renovate_coverage(): + """The canonical source files are never disabled — no blanket workflow ignore.""" + disabled = _renovate_disabled_paths("stranske/Workflows") + assert disabled == set() + + preset = json.loads(OWNERSHIP_PRESET.read_text(encoding="utf-8")) + patterns = {p for rule in preset["packageRules"] for p in rule["matchFileNames"]} + assert ".github/workflows/**" not in patterns