ctrlrelay 0.12.0__tar.gz → 0.12.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/test.yml +1 -6
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/CHANGELOG.md +14 -1
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/PKG-INFO +4 -4
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/README.md +3 -3
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/config/orchestrator.yaml.example +1 -1
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/bridge.md +5 -3
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/configuration.md +5 -4
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/pyproject.toml +1 -1
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/dev.py +42 -2
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/secops.py +42 -2
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/task.py +44 -2
- ctrlrelay-0.12.1/tests/test_docs_site.py +244 -0
- ctrlrelay-0.12.1/tests/test_resume_prompt_contract.py +255 -0
- ctrlrelay-0.12.0/tests/test_docs_site.py +0 -112
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/dependabot.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/build.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/cla.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/pages.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.github/workflows/publish.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/.gitignore +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/CODE_OF_CONDUCT.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/CONTRIBUTING.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/LICENSE +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/SECURITY.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/Gemfile +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/_config.yml +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/architecture.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/cli.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/development.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/feedback-loop.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/getting-started.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/index.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/operations.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/docs/personalization.md +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/__main__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/factory.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/mattermost_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/server.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/bridge/telegram_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/cli.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/archived.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/audit.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/checkpoint.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/code_review.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/config.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/dispatcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/github.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/network.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/obs.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/poller.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/pr_verifier.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/pr_watcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/question_expiry.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/scheduler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/state.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/core/worktree.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/dashboard/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/dashboard/client.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/gh_protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/install.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/manager.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/personalization/paths.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/base.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/pipelines/post_merge.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/setup.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/global-CLAUDE.md.snippet +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/bridge.plist.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/launchd/poller.plist.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/bridge.service.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/templates/systemd/poller.service.template +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/base.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/file_mock.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/src/ctrlrelay/transports/socket_client.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/__init__.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/conftest.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_archived.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_audit.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_bridge_protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_bridge_server.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_checkpoint.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_ci_wait.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_dev.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_repos.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_secops.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_start.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_cli_version.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_code_review.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_config.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dashboard_client.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dev_integration.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dev_pipeline.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_dispatcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_gh_protocol.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_github.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_install.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_mattermost_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_network.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_obs.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_personalization.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_pipeline_base.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_poller.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_post_merge.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_pr_verifier.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_pr_watcher.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_question_expiry.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_scheduler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_secops_integration.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_secops_pipeline.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_setup.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_state.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_task_pipeline.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_telegram_handler.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_transport.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_transport_resolution.py +0 -0
- {ctrlrelay-0.12.0 → ctrlrelay-0.12.1}/tests/test_worktree.py +0 -0
|
@@ -37,9 +37,4 @@ jobs:
|
|
|
37
37
|
run: uv run ruff check src/ tests/
|
|
38
38
|
|
|
39
39
|
- name: Pytest
|
|
40
|
-
|
|
41
|
-
# nav_order uniqueness across docs/ — tracked separately and
|
|
42
|
-
# unchanged by this PR.
|
|
43
|
-
run: |
|
|
44
|
-
uv run pytest tests/ -v \
|
|
45
|
-
--deselect tests/test_docs_site.py::test_nav_order_unique_per_sibling_group
|
|
40
|
+
run: uv run pytest tests/ -v
|
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.12.1] - 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Every checkpoint snippet creates its own directory (#190)
|
|
15
|
+
- Restate the checkpoint contract when resuming (#188)
|
|
16
|
+
- Run the whole suite, and make the next exclusion defendable (#186)
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Both-transports-at-once is not planned, not pending (#187)
|
|
21
|
+
|
|
10
22
|
## [0.12.0] - 2026-10-06
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -1671,7 +1683,8 @@ pipeline).
|
|
|
1671
1683
|
per-phase implementation plans (Phase 0 through Phase 4).
|
|
1672
1684
|
- `docs/Claude_Code_Project_Guide.md` — project development guide.
|
|
1673
1685
|
|
|
1674
|
-
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.
|
|
1686
|
+
[Unreleased]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.1...HEAD
|
|
1687
|
+
[0.12.1]: https://github.com/AInvirion/ctrlrelay/compare/v0.12.0...v0.12.1
|
|
1675
1688
|
[0.12.0]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.3...v0.12.0
|
|
1676
1689
|
[0.11.3]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.2...v0.11.3
|
|
1677
1690
|
[0.11.2]: https://github.com/AInvirion/ctrlrelay/compare/v0.11.1...v0.11.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrlrelay
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.1
|
|
4
4
|
Summary: Local-first orchestrator for headless coding agents across multiple GitHub repos
|
|
5
5
|
Project-URL: Homepage, https://github.com/AInvirion/ctrlrelay
|
|
6
6
|
Project-URL: Documentation, https://ainvirion.github.io/ctrlrelay/
|
|
@@ -97,9 +97,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
|
|
|
97
97
|
relays it to you and resumes the session once you reply. Choose
|
|
98
98
|
**Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
|
|
99
99
|
server) with one `transport.type` setting. Exactly one is active at a
|
|
100
|
-
time; running both at once is not supported
|
|
101
|
-
([#176](https://github.com/AInvirion/ctrlrelay/issues/176)
|
|
102
|
-
[configuration][docs-config].
|
|
100
|
+
time; running both at once is not supported and not planned
|
|
101
|
+
([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
|
|
102
|
+
won't-do). See [configuration][docs-config].
|
|
103
103
|
- **PR watcher.** Tracks the opened PR to merge and closes the loop
|
|
104
104
|
with a notification on whichever chat transport is configured.
|
|
105
105
|
- **In-process scheduler** (APScheduler). Runs periodic jobs inside
|
|
@@ -56,9 +56,9 @@ are on the roadmap (see [Roadmap](#roadmap)).
|
|
|
56
56
|
relays it to you and resumes the session once you reply. Choose
|
|
57
57
|
**Telegram** (a DM from a bot) or **Mattermost** (a channel on your own
|
|
58
58
|
server) with one `transport.type` setting. Exactly one is active at a
|
|
59
|
-
time; running both at once is not supported
|
|
60
|
-
([#176](https://github.com/AInvirion/ctrlrelay/issues/176)
|
|
61
|
-
[configuration][docs-config].
|
|
59
|
+
time; running both at once is not supported and not planned
|
|
60
|
+
([#176](https://github.com/AInvirion/ctrlrelay/issues/176), closed
|
|
61
|
+
won't-do). See [configuration][docs-config].
|
|
62
62
|
- **PR watcher.** Tracks the opened PR to merge and closes the loop
|
|
63
63
|
with a notification on whichever chat transport is configured.
|
|
64
64
|
- **In-process scheduler** (APScheduler). Runs periodic jobs inside
|
|
@@ -34,7 +34,7 @@ agent:
|
|
|
34
34
|
# EXACTLY ONE transport is active, and it is the one named by `type`.
|
|
35
35
|
# The other blocks below are inert: they may stay in the file, but nothing
|
|
36
36
|
# reads them. Running Telegram and Mattermost at the same time is NOT
|
|
37
|
-
# supported
|
|
37
|
+
# supported and NOT planned - designed in issue #176 and closed won't-do.
|
|
38
38
|
#
|
|
39
39
|
# Choose:
|
|
40
40
|
# type: "telegram" production, 1:1 chat with a bot
|
|
@@ -22,9 +22,11 @@ one-line config change and no pipeline code moves.
|
|
|
22
22
|
**One at a time.** `transport.type` names the transport that is active, and the
|
|
23
23
|
other block is inert — it may stay in the file, but nothing reads it, including
|
|
24
24
|
its `question_ttl_seconds`. There is no fallback: a question goes to the one
|
|
25
|
-
you named or nowhere. Running both at once is
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
you named or nowhere. Running both at once is **not supported and not
|
|
26
|
+
planned** — it was designed in
|
|
27
|
+
[#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed
|
|
28
|
+
won't-do. Reopen that issue rather than re-deriving the design if you need
|
|
29
|
+
it.
|
|
28
30
|
|
|
29
31
|
| | Telegram | Mattermost |
|
|
30
32
|
|---|---|---|
|
|
@@ -104,10 +104,11 @@ block in the file is inert — it may stay there, but nothing reads it.
|
|
|
104
104
|
| A channel on your own server | `"mattermost"` | A server URL, a bot token, and the channel's 26-character ID. |
|
|
105
105
|
| No chat at all | `"file_mock"` | Nothing. Questions go to a file; **nobody is asked anything.** |
|
|
106
106
|
|
|
107
|
-
**Running Telegram and Mattermost at the same time is not supported
|
|
108
|
-
`type` to one does not fall back to the other, and a
|
|
109
|
-
one place or not at all.
|
|
110
|
-
[
|
|
107
|
+
**Running Telegram and Mattermost at the same time is not supported, and is
|
|
108
|
+
not planned.** Setting `type` to one does not fall back to the other, and a
|
|
109
|
+
question is delivered to one place or not at all. It was designed in
|
|
110
|
+
[#176](https://github.com/AInvirion/ctrlrelay/issues/176) and closed won't-do;
|
|
111
|
+
the design is still in that issue if anyone wants it.
|
|
111
112
|
|
|
112
113
|
Switching is a two-line change — set `type`, add that transport's block — plus
|
|
113
114
|
a restart of **both** daemons, which share `state.db`.
|
|
@@ -318,7 +318,20 @@ class DevPipeline:
|
|
|
318
318
|
|
|
319
319
|
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
320
320
|
"""Resume blocked dev session with user answer."""
|
|
321
|
-
|
|
321
|
+
# The checkpoint contract is restated here, not just in the
|
|
322
|
+
# initial prompt (#173). A resumed agent that is told "continue"
|
|
323
|
+
# and nothing else can do its work, exit 0, write no state, and
|
|
324
|
+
# be indistinguishable from one that crashed - which is what
|
|
325
|
+
# happened on 2026-09-16, after three of four pull requests had
|
|
326
|
+
# already been merged.
|
|
327
|
+
contract = self._checkpoint_contract(
|
|
328
|
+
str(ctx.state_file), ctx.session_id
|
|
329
|
+
)
|
|
330
|
+
prompt = (
|
|
331
|
+
f"User answered: {answer}\n\n"
|
|
332
|
+
"Continue from where you left off.\n\n"
|
|
333
|
+
f"{contract}"
|
|
334
|
+
)
|
|
322
335
|
|
|
323
336
|
# Resume uses Claude's own session UUID (captured on the first spawn
|
|
324
337
|
# and persisted to state_db). Passing our composite id here makes
|
|
@@ -444,7 +457,34 @@ Do NOT merge the PR - wait for human review.
|
|
|
444
457
|
The orchestrator re-verifies CI and mergeability after you hand off; if it
|
|
445
458
|
finds the PR broken it will resume this session asking you to fix it.
|
|
446
459
|
|
|
447
|
-
|
|
460
|
+
{self._checkpoint_contract(state_file_path, session_id)}"""
|
|
461
|
+
|
|
462
|
+
def _checkpoint_contract(
|
|
463
|
+
self, state_file_path: str, session_id: str
|
|
464
|
+
) -> str:
|
|
465
|
+
"""How the agent signals how it stopped.
|
|
466
|
+
|
|
467
|
+
Extracted so the RESUME prompt can carry it too (#173). The
|
|
468
|
+
initial prompt spent ~30 lines establishing this; the resume
|
|
469
|
+
prompt was 58 characters and said only "continue", so a resumed
|
|
470
|
+
agent could do its work, exit 0, write no state, and be
|
|
471
|
+
indistinguishable from one that crashed.
|
|
472
|
+
|
|
473
|
+
Measured case, 2026-09-16:
|
|
474
|
+
secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
|
|
475
|
+
"Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
|
|
476
|
+
in fact merged three of four pull requests. The operator was told
|
|
477
|
+
the resume failed, which was wrong in both directions: most of
|
|
478
|
+
the work had landed, and the one thing outstanding was never
|
|
479
|
+
named.
|
|
480
|
+
|
|
481
|
+
Per-pipeline rather than shared, because the three contracts
|
|
482
|
+
differ in substance and not only wording: the dev DONE block
|
|
483
|
+
carries pr_url and pr_number outputs, and each names different
|
|
484
|
+
conditions for DONE. One shared block would have quietly changed
|
|
485
|
+
what two of them promise.
|
|
486
|
+
"""
|
|
487
|
+
return f"""## Signaling Completion
|
|
448
488
|
|
|
449
489
|
**CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
|
|
450
490
|
|
|
@@ -126,7 +126,20 @@ class SecopsPipeline:
|
|
|
126
126
|
|
|
127
127
|
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
128
128
|
"""Resume blocked secops with user answer."""
|
|
129
|
-
|
|
129
|
+
# The checkpoint contract is restated here, not just in the
|
|
130
|
+
# initial prompt (#173). A resumed agent that is told "continue"
|
|
131
|
+
# and nothing else can do its work, exit 0, write no state, and
|
|
132
|
+
# be indistinguishable from one that crashed - which is what
|
|
133
|
+
# happened on 2026-09-16, after three of four pull requests had
|
|
134
|
+
# already been merged.
|
|
135
|
+
contract = self._checkpoint_contract(
|
|
136
|
+
str(ctx.state_file), ctx.session_id
|
|
137
|
+
)
|
|
138
|
+
prompt = (
|
|
139
|
+
f"User answered: {answer}\n\n"
|
|
140
|
+
"Continue from where you left off.\n\n"
|
|
141
|
+
f"{contract}"
|
|
142
|
+
)
|
|
130
143
|
|
|
131
144
|
# Resume uses Claude's own session UUID (captured on the first spawn
|
|
132
145
|
# and persisted to state_db). Our composite id is not a UUID and will
|
|
@@ -425,7 +438,34 @@ yourself about to write a phrase like "no open PRs at all" or "N open
|
|
|
425
438
|
PRs total", you have already stepped outside the fence — you can only
|
|
426
439
|
speak to the author-filtered results.
|
|
427
440
|
|
|
428
|
-
|
|
441
|
+
{self._checkpoint_contract(state_file_path, session_id)}"""
|
|
442
|
+
|
|
443
|
+
def _checkpoint_contract(
|
|
444
|
+
self, state_file_path: str, session_id: str
|
|
445
|
+
) -> str:
|
|
446
|
+
"""How the agent signals how it stopped.
|
|
447
|
+
|
|
448
|
+
Extracted so the RESUME prompt can carry it too (#173). The
|
|
449
|
+
initial prompt spent ~30 lines establishing this; the resume
|
|
450
|
+
prompt was 58 characters and said only "continue", so a resumed
|
|
451
|
+
agent could do its work, exit 0, write no state, and be
|
|
452
|
+
indistinguishable from one that crashed.
|
|
453
|
+
|
|
454
|
+
Measured case, 2026-09-16:
|
|
455
|
+
secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
|
|
456
|
+
"Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
|
|
457
|
+
in fact merged three of four pull requests. The operator was told
|
|
458
|
+
the resume failed, which was wrong in both directions: most of
|
|
459
|
+
the work had landed, and the one thing outstanding was never
|
|
460
|
+
named.
|
|
461
|
+
|
|
462
|
+
Per-pipeline rather than shared, because the three contracts
|
|
463
|
+
differ in substance and not only wording: the dev DONE block
|
|
464
|
+
carries pr_url and pr_number outputs, and each names different
|
|
465
|
+
conditions for DONE. One shared block would have quietly changed
|
|
466
|
+
what two of them promise.
|
|
467
|
+
"""
|
|
468
|
+
return f"""## Signaling Completion
|
|
429
469
|
|
|
430
470
|
**CRITICAL**: Before exiting, you MUST write a checkpoint file to signal completion.
|
|
431
471
|
|
|
@@ -80,7 +80,20 @@ class TaskPipeline:
|
|
|
80
80
|
return self._session_to_result(result)
|
|
81
81
|
|
|
82
82
|
async def resume(self, ctx: PipelineContext, answer: str) -> PipelineResult:
|
|
83
|
-
|
|
83
|
+
# The checkpoint contract is restated here, not just in the
|
|
84
|
+
# initial prompt (#173). A resumed agent that is told "continue"
|
|
85
|
+
# and nothing else can do its work, exit 0, write no state, and
|
|
86
|
+
# be indistinguishable from one that crashed - which is what
|
|
87
|
+
# happened on 2026-09-16, after three of four pull requests had
|
|
88
|
+
# already been merged.
|
|
89
|
+
contract = self._checkpoint_contract(
|
|
90
|
+
str(ctx.state_file), ctx.session_id
|
|
91
|
+
)
|
|
92
|
+
prompt = (
|
|
93
|
+
f"User answered: {answer}\n\n"
|
|
94
|
+
"Continue from where you left off.\n\n"
|
|
95
|
+
f"{contract}"
|
|
96
|
+
)
|
|
84
97
|
resume_uuid = self.state_db.get_agent_session_id(ctx.session_id)
|
|
85
98
|
log_event(
|
|
86
99
|
_logger,
|
|
@@ -181,7 +194,34 @@ pipeline. Don't BLOCK on things you can figure out yourself.
|
|
|
181
194
|
|
|
182
195
|
## Signaling completion
|
|
183
196
|
|
|
184
|
-
|
|
197
|
+
{self._checkpoint_contract(state_file_path, session_id)}"""
|
|
198
|
+
|
|
199
|
+
def _checkpoint_contract(
|
|
200
|
+
self, state_file_path: str, session_id: str
|
|
201
|
+
) -> str:
|
|
202
|
+
"""How the agent signals how it stopped.
|
|
203
|
+
|
|
204
|
+
Extracted so the RESUME prompt can carry it too (#173). The
|
|
205
|
+
initial prompt spent ~30 lines establishing this; the resume
|
|
206
|
+
prompt was 58 characters and said only "continue", so a resumed
|
|
207
|
+
agent could do its work, exit 0, write no state, and be
|
|
208
|
+
indistinguishable from one that crashed.
|
|
209
|
+
|
|
210
|
+
Measured case, 2026-09-16:
|
|
211
|
+
secops-AInvirion-Product-Telemetry-bbb99925 resumed after an
|
|
212
|
+
"Approved", ran 27.4s, exited 0 and wrote no checkpoint - having
|
|
213
|
+
in fact merged three of four pull requests. The operator was told
|
|
214
|
+
the resume failed, which was wrong in both directions: most of
|
|
215
|
+
the work had landed, and the one thing outstanding was never
|
|
216
|
+
named.
|
|
217
|
+
|
|
218
|
+
Per-pipeline rather than shared, because the three contracts
|
|
219
|
+
differ in substance and not only wording: the dev DONE block
|
|
220
|
+
carries pr_url and pr_number outputs, and each names different
|
|
221
|
+
conditions for DONE. One shared block would have quietly changed
|
|
222
|
+
what two of them promise.
|
|
223
|
+
"""
|
|
224
|
+
return f"""**CRITICAL**: Before exiting, write a checkpoint file.
|
|
185
225
|
|
|
186
226
|
STATE_FILE: {state_file_path}
|
|
187
227
|
SESSION_ID: {session_id}
|
|
@@ -196,6 +236,7 @@ printf '{{"version":"1","status":"DONE","session_id":"{session_id}",'\\
|
|
|
196
236
|
|
|
197
237
|
**BLOCKED** (need operator input):
|
|
198
238
|
```bash
|
|
239
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
199
240
|
printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
|
|
200
241
|
'"session_id":"{session_id}","timestamp":"%s","question":"%s"}}' \\
|
|
201
242
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<QUESTION>" > '{state_file_path}'
|
|
@@ -203,6 +244,7 @@ printf '{{"version":"1","status":"BLOCKED_NEEDS_INPUT",'\\
|
|
|
203
244
|
|
|
204
245
|
**FAILED** (genuine failure, no way forward):
|
|
205
246
|
```bash
|
|
247
|
+
mkdir -p "$(dirname '{state_file_path}')"
|
|
206
248
|
printf '{{"version":"1","status":"FAILED",'\\
|
|
207
249
|
'"session_id":"{session_id}","timestamp":"%s","error":"%s"}}' \\
|
|
208
250
|
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "<ERROR>" > '{state_file_path}'
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Validation tests for the Jekyll documentation site under ``docs/``.
|
|
2
|
+
|
|
3
|
+
These tests do not invoke Jekyll. They assert the structural invariants the
|
|
4
|
+
site relies on so a misconfigured page is caught before GitHub Pages builds.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
16
|
+
DOCS = REPO_ROOT / "docs"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _split_front_matter(text: str) -> tuple[dict, str]:
|
|
20
|
+
if not text.startswith("---\n"):
|
|
21
|
+
return {}, text
|
|
22
|
+
end = text.find("\n---", 4)
|
|
23
|
+
if end == -1:
|
|
24
|
+
return {}, text
|
|
25
|
+
raw = text[4:end]
|
|
26
|
+
body = text[end + 4 :].lstrip("\n")
|
|
27
|
+
data = yaml.safe_load(raw) or {}
|
|
28
|
+
if not isinstance(data, dict):
|
|
29
|
+
raise ValueError("front matter must be a mapping")
|
|
30
|
+
return data, body
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _markdown_files() -> list[Path]:
|
|
34
|
+
return sorted(p for p in DOCS.rglob("*.md") if "_site" not in p.parts)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_config_is_valid_yaml():
|
|
38
|
+
config_path = DOCS / "_config.yml"
|
|
39
|
+
assert config_path.exists(), "docs/_config.yml must exist"
|
|
40
|
+
data = yaml.safe_load(config_path.read_text())
|
|
41
|
+
assert isinstance(data, dict)
|
|
42
|
+
assert data.get("remote_theme"), "remote_theme must be set for GitHub Pages"
|
|
43
|
+
assert "jekyll-remote-theme" in (data.get("plugins") or []), (
|
|
44
|
+
"jekyll-remote-theme plugin required"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_landing_page_exists():
|
|
49
|
+
assert (DOCS / "index.md").exists(), "docs/index.md landing page required"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_all_markdown_pages_have_title_front_matter():
|
|
53
|
+
missing: list[str] = []
|
|
54
|
+
for md in _markdown_files():
|
|
55
|
+
front, _ = _split_front_matter(md.read_text())
|
|
56
|
+
if not front.get("title"):
|
|
57
|
+
missing.append(str(md.relative_to(REPO_ROOT)))
|
|
58
|
+
assert not missing, f"pages missing title front matter: {missing}"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_parent_pages_declare_has_children():
|
|
62
|
+
"""Any page referenced as ``parent`` must itself set ``has_children: true``."""
|
|
63
|
+
titles_with_children: set[str] = set()
|
|
64
|
+
referenced_parents: set[str] = set()
|
|
65
|
+
for md in _markdown_files():
|
|
66
|
+
front, _ = _split_front_matter(md.read_text())
|
|
67
|
+
if front.get("has_children"):
|
|
68
|
+
titles_with_children.add(front["title"])
|
|
69
|
+
parent = front.get("parent")
|
|
70
|
+
if parent:
|
|
71
|
+
referenced_parents.add(parent)
|
|
72
|
+
missing = referenced_parents - titles_with_children
|
|
73
|
+
assert not missing, f"parents referenced but not declared with has_children: {missing}"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_nav_order_unique_per_sibling_group():
|
|
77
|
+
"""Pages sharing a parent (or all top-level) must have distinct nav_order."""
|
|
78
|
+
groups: dict[str, dict[int, list[str]]] = defaultdict(lambda: defaultdict(list))
|
|
79
|
+
for md in _markdown_files():
|
|
80
|
+
front, _ = _split_front_matter(md.read_text())
|
|
81
|
+
parent = front.get("parent", "__root__")
|
|
82
|
+
order = front.get("nav_order")
|
|
83
|
+
if order is None:
|
|
84
|
+
continue
|
|
85
|
+
groups[parent][order].append(str(md.relative_to(REPO_ROOT)))
|
|
86
|
+
for parent, by_order in groups.items():
|
|
87
|
+
collisions = {o: files for o, files in by_order.items() if len(files) > 1}
|
|
88
|
+
assert not collisions, (
|
|
89
|
+
f"duplicate nav_order under parent {parent!r}: {collisions}"
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_no_stray_bare_markdown_outside_structure():
|
|
94
|
+
"""Every page must either be the index, a top-level nav page, or have a parent."""
|
|
95
|
+
offenders: list[str] = []
|
|
96
|
+
for md in _markdown_files():
|
|
97
|
+
rel = md.relative_to(DOCS)
|
|
98
|
+
if rel.name == "index.md":
|
|
99
|
+
continue
|
|
100
|
+
front, _ = _split_front_matter(md.read_text())
|
|
101
|
+
if front.get("parent") or front.get("nav_order") is not None:
|
|
102
|
+
continue
|
|
103
|
+
offenders.append(str(md.relative_to(REPO_ROOT)))
|
|
104
|
+
assert not offenders, (
|
|
105
|
+
f"pages without parent or top-level nav_order: {offenders}"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.parametrize("path", _markdown_files())
|
|
110
|
+
def test_front_matter_parses(path: Path):
|
|
111
|
+
front, _ = _split_front_matter(path.read_text())
|
|
112
|
+
assert isinstance(front, dict)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def test_ci_runs_the_whole_suite():
|
|
116
|
+
"""No workflow may exclude tests from the run (#179).
|
|
117
|
+
|
|
118
|
+
`test.yml` carried `--deselect
|
|
119
|
+
tests/test_docs_site.py::test_nav_order_unique_per_sibling_group` under
|
|
120
|
+
the comment "tracked separately and unchanged by this PR". The test
|
|
121
|
+
passed the whole time - verified by making two pages collide on
|
|
122
|
+
`nav_order`, which it catches and names both files for - so CI had
|
|
123
|
+
been skipping a working guard for months and under-counting itself.
|
|
124
|
+
|
|
125
|
+
The transferable part is the justification, not the flag. "Unchanged
|
|
126
|
+
by this PR" is a pull-request-scoped reason frozen into a file that
|
|
127
|
+
runs for every change. It was true the day it was written and stays
|
|
128
|
+
plausible forever, because it is true of almost every pull request -
|
|
129
|
+
so every reader nods and moves on. **Such a reason cannot expire on
|
|
130
|
+
its own.**
|
|
131
|
+
|
|
132
|
+
This makes the next exclusion something that has to be defended
|
|
133
|
+
rather than inherited: adding one fails here, and the person adding
|
|
134
|
+
it has to come and say why in this file, where a reviewer will see
|
|
135
|
+
it next to this paragraph.
|
|
136
|
+
|
|
137
|
+
If an exclusion is ever genuinely needed, prefer deleting or fixing
|
|
138
|
+
the test. Do not leave the third state - a test that exists, is not
|
|
139
|
+
run, and is believed to be covering something.
|
|
140
|
+
|
|
141
|
+
**Reads the parsed `run:` values, not lines of the file.** A review
|
|
142
|
+
round found three faults in the line-grep version, and parsing fixes
|
|
143
|
+
all of them: a step `name:` mentioning a flag was reported as an
|
|
144
|
+
offender (a false positive invites relaxing the guard, and a relaxed
|
|
145
|
+
guard stops guarding); `--ignore tests/x` with a space slipped past a
|
|
146
|
+
pattern that required `--ignore=`; and the guard reported clean when
|
|
147
|
+
it had inspected no pytest command at all.
|
|
148
|
+
|
|
149
|
+
**What this does NOT stop, stated so the paragraph above is not the
|
|
150
|
+
false sentence.** It reads text, so an author actively evading it
|
|
151
|
+
wins: `--ig"nore"` reaches pytest as `--ignore` and no pattern here
|
|
152
|
+
sees it. That is not worth chasing - every fix is defeated by the
|
|
153
|
+
next quoting trick, and anyone editing the workflow to dodge this
|
|
154
|
+
guard can edit this file just as easily. The fault in #179 was
|
|
155
|
+
**inherited, not evasive**: a reason that was true once, left in
|
|
156
|
+
place, nodded past by every later reader. That is what this catches.
|
|
157
|
+
|
|
158
|
+
One known false positive: a run script that *prints* an example
|
|
159
|
+
containing an exclusion flag - `echo "e.g. pytest --ignore tests/x"` -
|
|
160
|
+
is reported. Deliberately not fixed, because telling the quoted from
|
|
161
|
+
the executed needs a shell parser, which is more machinery than the
|
|
162
|
+
defect. Do not write that line; if you must, name it here.
|
|
163
|
+
"""
|
|
164
|
+
import re
|
|
165
|
+
|
|
166
|
+
import yaml
|
|
167
|
+
|
|
168
|
+
wf_dir = REPO_ROOT / ".github" / "workflows"
|
|
169
|
+
workflows = sorted(
|
|
170
|
+
[*wf_dir.glob("*.yml"), *wf_dir.glob("*.yaml")]
|
|
171
|
+
)
|
|
172
|
+
assert len(workflows) >= 3, f"only found {len(workflows)} workflows"
|
|
173
|
+
|
|
174
|
+
# Every `run:` script in every step, with where it came from.
|
|
175
|
+
runs: list[tuple[str, str]] = []
|
|
176
|
+
for wf in workflows:
|
|
177
|
+
doc = yaml.safe_load(wf.read_text()) or {}
|
|
178
|
+
for job_name, job in (doc.get("jobs") or {}).items():
|
|
179
|
+
for step in (job or {}).get("steps") or []:
|
|
180
|
+
script = (step or {}).get("run")
|
|
181
|
+
if isinstance(script, str):
|
|
182
|
+
runs.append((f"{wf.name}:{job_name}", script))
|
|
183
|
+
|
|
184
|
+
def _code_lines(script: str) -> list[str]:
|
|
185
|
+
"""The lines of a run script that are not comments."""
|
|
186
|
+
return [
|
|
187
|
+
line
|
|
188
|
+
for line in script.splitlines()
|
|
189
|
+
if not line.lstrip().startswith("#")
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
invocations = [
|
|
193
|
+
(where, script)
|
|
194
|
+
for where, script in runs
|
|
195
|
+
if any(
|
|
196
|
+
re.search(r"(?<![\w-])pytest(?![\w-])", line)
|
|
197
|
+
for line in _code_lines(script)
|
|
198
|
+
)
|
|
199
|
+
]
|
|
200
|
+
|
|
201
|
+
# Invariant D, as a POSITIVE assertion. `offenders == []` alone passes
|
|
202
|
+
# when no pytest command exists at all - a workflow replaced with
|
|
203
|
+
# `run: true` would have reported clean.
|
|
204
|
+
assert invocations, (
|
|
205
|
+
"no workflow step runs pytest. Either CI stopped running the "
|
|
206
|
+
"suite, or this guard can no longer see it - both are faults, "
|
|
207
|
+
f"and neither is a clean result. Scanned: {[w for w, _ in runs]}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Flags that remove tests from a run, accepting `=` or a space.
|
|
211
|
+
# `--maxfail` is not one: it stops early on failure, it does not hide
|
|
212
|
+
# a passing test. `-m` selects by marker, which this suite does not
|
|
213
|
+
# use for exclusion.
|
|
214
|
+
excluders = re.compile(
|
|
215
|
+
r"--deselect(?:[=\s]|$)"
|
|
216
|
+
r"|--ignore(?:-glob)?(?:[=\s]|$)"
|
|
217
|
+
r"|(?<!\w)-k(?:[=\s]|$)"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
offenders: list[str] = []
|
|
221
|
+
for where, script in invocations:
|
|
222
|
+
for lineno, line in enumerate(script.splitlines(), start=1):
|
|
223
|
+
if line.lstrip().startswith("#"):
|
|
224
|
+
continue
|
|
225
|
+
if excluders.search(line):
|
|
226
|
+
offenders.append(f"{where} run line {lineno}: {line.strip()}")
|
|
227
|
+
|
|
228
|
+
# Narrowing the target paths excludes tests without any flag at
|
|
229
|
+
# all, which is the bypass a flag list cannot see.
|
|
230
|
+
targets = [
|
|
231
|
+
tok
|
|
232
|
+
for tok in script.split()
|
|
233
|
+
if tok.startswith("tests") and not tok.startswith("-")
|
|
234
|
+
]
|
|
235
|
+
if targets and not any(t.rstrip("/") == "tests" for t in targets):
|
|
236
|
+
offenders.append(
|
|
237
|
+
f"{where}: runs only {targets}, not the whole tests/ tree"
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
assert offenders == [], (
|
|
241
|
+
"a workflow excludes tests from the run. Delete the test, fix it, "
|
|
242
|
+
"or change this guard and say why here - do not leave a test that "
|
|
243
|
+
f"exists, is not run, and is assumed to pass (#179): {offenders}"
|
|
244
|
+
)
|