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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .agents/skills/shepherd-driver/scripts/owner_touch_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import hashlib
import json
import re
import shutil
import subprocess
import sys
from dataclasses import dataclass
Expand All @@ -26,6 +27,27 @@
"split-authority-repair",
}

# Cached, PATH-resolved git executable. This script ships as a
# standalone, portable APM skill artifact (see packages/shepherd-driver)
# and must not depend on the main repo's apm_cli package being
# importable, so it resolves git itself rather than reusing
# apm_cli.utils.git_env.get_git_executable(). A bare "git" argv does
# not reliably resolve on Windows (CreateProcess with shell=False does
# not perform a PATH search for extension-less names), which raised
# FileNotFoundError: [WinError 2] (see microsoft/apm#2233).
_GIT_EXECUTABLE: str | None = None


def _git_executable() -> str:
"""Return the cached, fully-resolved path to the git executable."""
global _GIT_EXECUTABLE
if _GIT_EXECUTABLE is None:
resolved = shutil.which("git")
if resolved is None:
raise GateError("git executable not found on PATH")
_GIT_EXECUTABLE = resolved
return _GIT_EXECUTABLE


class GateError(RuntimeError):
"""Raised when owner detection or evidence verification must fail closed."""
Expand All @@ -50,8 +72,8 @@ def _git(repo_root: Path, *args: str, text: Literal[False]) -> bytes: ...

def _git(repo_root: Path, *args: str, text: bool = True) -> str | bytes:
"""Run git in repo_root and return stdout, raising GateError on failure."""
command = ["git", "-C", str(repo_root), *args]
completed = subprocess.run( # noqa: S603 - fixed git executable, no shell
command = [_git_executable(), "-C", str(repo_root), *args]
completed = subprocess.run( # noqa: S603 - fixed, PATH-resolved git executable, no shell
command,
check=False,
capture_output=True,
Expand Down
88 changes: 88 additions & 0 deletions .apm/instructions/tests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,91 @@ and review.
reads `APM_RUN_INTEGRATION_TESTS` to branch behaviour, the marker
is wrong (or missing). The marker is the gate; the test body
should assume the gate already passed.

## The `windows_compat` marker (cross-platform regression contract)

`windows_compat` is a **scheduling marker**, not a prerequisite
marker. Do not confuse it with `requires_windows` -- there is no
`requires_windows` marker in this repo, and there must never be one
used for this purpose. `windows_compat` tests run on **every OS**
(Linux, macOS, Windows) as part of the normal unit suite; the marker
additionally selects them for a dedicated, focused PR-time job
(`windows-compat-gate` in `.github/workflows/ci.yml`, `runs-on:
windows-latest`) that runs `pytest -m windows_compat tests/unit`.
That job exists because the full Windows matrix in
`build-release.yml` only runs post-merge -- without this gate, a
Windows-only regression (CRLF line endings, backslash path
separators, bare `git` argv resolution, socket/thread shutdown races,
etc.) is structurally invisible until after the PR has already
merged. See microsoft/apm#2233 for the incident that motivated it.

### When to add the marker

Add `pytest.mark.windows_compat` to a test when it is a **load-bearing
regression proof for a cross-platform defect class** -- i.e. it would
have caught a bug that only manifests on Windows (or that a
non-portable implementation could silently reintroduce), such as:

- Text/JSON writers that must produce LF-only, atomic, ASCII-safe
output regardless of platform line-ending or encoding defaults.
- Path formatting that must stay POSIX-style in diagnostics/output
even when the underlying OS uses backslash separators.
- Subprocess invocation that must resolve an executable name (e.g.
`git`) portably instead of assuming a POSIX `$PATH` lookup.
- Socket/thread lifecycle code whose shutdown path differs by OS
(e.g. Windows-specific WinError codes needing a narrow, proven
catch -- never a broad exception swallow).

Do **not** add the marker to a whole file just because it happens to
live near Windows-relevant code -- mark only the specific tests that
assert the cross-platform contract, unless (per module) virtually
every test in the file already exercises that same code path (in
which case a module-level `pytestmark = pytest.mark.windows_compat`
is the right level of granularity -- see
`tests/unit/test_shepherd_owner_touch_gate.py` for an example where
nearly every test goes through the same git-executable-resolution
helper).

### Procedure

1. Register the marker once in `pyproject.toml` under
`[tool.pytest.ini_options].markers` (already done -- do not
re-register). `--strict-markers` is set, so an unregistered marker
name fails collection immediately; this is deliberate and must not
be worked around with a bare string literal.
2. Apply the marker at the narrowest level that is true:
```python
@pytest.mark.windows_compat
def test_write_report_is_deterministic_atomic_and_printable_ascii():
...
```
or, for a module dedicated to the contract family:
```python
pytestmark = pytest.mark.windows_compat
```
3. Do **not** edit `.github/workflows/ci.yml` to add your test file --
the gate selects by marker, not by file enumeration. If your test
is properly marked, it is picked up automatically the next PR run.
4. Confirm collection locally:
```bash
uv run --extra dev pytest -m windows_compat tests/unit --collect-only -q
```
5. `tests/unit/test_windows_compat_gate_workflow.py` asserts the
*shape* of the gate (marker-scoped, bounded, non-empty, required,
non-duplicative of the Linux full suite) -- it does not enumerate
file names, so it does not need editing when you add or remove a
marked test.

### Anti-patterns

- **Enumerating test files in the workflow instead of using the
marker.** This is the exact drift the marker-based gate replaces;
a file list silently goes stale as tests move or get renamed.
- **Using `requires_windows` (or inventing it) for this purpose.**
That name implies "only runs on Windows" -- the opposite of what
this marker means. These tests are cross-platform regression
proofs that run everywhere.
- **Marking a whole large, multi-purpose test file** when only a
handful of its tests actually assert the Windows-regression
contract -- this bloats the PR-time gate with unrelated coverage
that belongs to the ordinary Linux unit run instead.
88 changes: 88 additions & 0 deletions .github/instructions/tests.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,3 +229,91 @@ and review.
reads `APM_RUN_INTEGRATION_TESTS` to branch behaviour, the marker
is wrong (or missing). The marker is the gate; the test body
should assume the gate already passed.

## The `windows_compat` marker (cross-platform regression contract)

`windows_compat` is a **scheduling marker**, not a prerequisite
marker. Do not confuse it with `requires_windows` -- there is no
`requires_windows` marker in this repo, and there must never be one
used for this purpose. `windows_compat` tests run on **every OS**
(Linux, macOS, Windows) as part of the normal unit suite; the marker
additionally selects them for a dedicated, focused PR-time job
(`windows-compat-gate` in `.github/workflows/ci.yml`, `runs-on:
windows-latest`) that runs `pytest -m windows_compat tests/unit`.
That job exists because the full Windows matrix in
`build-release.yml` only runs post-merge -- without this gate, a
Windows-only regression (CRLF line endings, backslash path
separators, bare `git` argv resolution, socket/thread shutdown races,
etc.) is structurally invisible until after the PR has already
merged. See microsoft/apm#2233 for the incident that motivated it.

### When to add the marker

Add `pytest.mark.windows_compat` to a test when it is a **load-bearing
regression proof for a cross-platform defect class** -- i.e. it would
have caught a bug that only manifests on Windows (or that a
non-portable implementation could silently reintroduce), such as:

- Text/JSON writers that must produce LF-only, atomic, ASCII-safe
output regardless of platform line-ending or encoding defaults.
- Path formatting that must stay POSIX-style in diagnostics/output
even when the underlying OS uses backslash separators.
- Subprocess invocation that must resolve an executable name (e.g.
`git`) portably instead of assuming a POSIX `$PATH` lookup.
- Socket/thread lifecycle code whose shutdown path differs by OS
(e.g. Windows-specific WinError codes needing a narrow, proven
catch -- never a broad exception swallow).

Do **not** add the marker to a whole file just because it happens to
live near Windows-relevant code -- mark only the specific tests that
assert the cross-platform contract, unless (per module) virtually
every test in the file already exercises that same code path (in
which case a module-level `pytestmark = pytest.mark.windows_compat`
is the right level of granularity -- see
`tests/unit/test_shepherd_owner_touch_gate.py` for an example where
nearly every test goes through the same git-executable-resolution
helper).

### Procedure

1. Register the marker once in `pyproject.toml` under
`[tool.pytest.ini_options].markers` (already done -- do not
re-register). `--strict-markers` is set, so an unregistered marker
name fails collection immediately; this is deliberate and must not
be worked around with a bare string literal.
2. Apply the marker at the narrowest level that is true:
```python
@pytest.mark.windows_compat
def test_write_report_is_deterministic_atomic_and_printable_ascii():
...
```
or, for a module dedicated to the contract family:
```python
pytestmark = pytest.mark.windows_compat
```
3. Do **not** edit `.github/workflows/ci.yml` to add your test file --
the gate selects by marker, not by file enumeration. If your test
is properly marked, it is picked up automatically the next PR run.
4. Confirm collection locally:
```bash
uv run --extra dev pytest -m windows_compat tests/unit --collect-only -q
```
5. `tests/unit/test_windows_compat_gate_workflow.py` asserts the
*shape* of the gate (marker-scoped, bounded, non-empty, required,
non-duplicative of the Linux full suite) -- it does not enumerate
file names, so it does not need editing when you add or remove a
marked test.

### Anti-patterns

- **Enumerating test files in the workflow instead of using the
marker.** This is the exact drift the marker-based gate replaces;
a file list silently goes stale as tests move or get renamed.
- **Using `requires_windows` (or inventing it) for this purpose.**
That name implies "only runs on Windows" -- the opposite of what
this marker means. These tests are cross-platform regression
proofs that run everywhere.
- **Marking a whole large, multi-purpose test file** when only a
handful of its tests actually assert the Windows-regression
contract -- this bloats the PR-time gate with unrelated coverage
that belongs to the ordinary Linux unit run instead.
59 changes: 59 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,65 @@ jobs:
- name: Lint - architecture authority boundaries
run: bash scripts/lint-architecture-boundaries.sh

# Focused Windows compatibility gate -- runs at PR time (not just
# post-merge in build-release.yml) so the class of Windows-only
# regressions fixed here (CRLF text-mode writes, Windows path
# separators leaking into diagnostics, bare "git" argv resolution,
# and the websockets.sync shutdown race) is caught before merge
# instead of surfacing only in the post-merge full-matrix build (see
# microsoft/apm#2233). Deliberately narrow: selects the load-bearing
# cross-platform contract family declaratively via the `windows_compat`
# pytest marker (see pyproject.toml) rather than enumerating test
# files here -- adding a new Windows-relevant regression test only
# requires marking it, not editing this workflow. Not a full-suite
# duplicate: keeps PR-time cost bounded while still gating the exact
# defect class that slipped through Linux-only CI.
windows-compat-gate:
name: Windows Compatibility Gate
runs-on: windows-latest
timeout-minutes: 15
permissions:
contents: read

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Install dependencies
run: uv sync --extra dev

- name: Diagnostics (platform, git, python)
run: |
python --version
python -c "import sys; print('platform:', sys.platform)"
git --version
where git

- name: Run cross-platform contract family
run: >-
uv run --extra dev pytest -p no:cacheprovider -v
-m windows_compat
tests/unit

- name: Diagnostics on failure
if: failure()
run: |
echo "::group::Environment"
Get-ChildItem Env: | Sort-Object Name
echo "::endgroup::"
echo "::group::git config"
git config --list --show-origin
echo "::endgroup::"

test-architecture:
name: Test Architecture Ratchets
runs-on: ubuntu-24.04
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/merge-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,13 @@ jobs:
# allocation latency on the critical path.
# merge_group context: Coverage Combine IS required, so the
# global 80% floor blocks the actual merge.
EXPECTED_CHECKS: ${{ github.event_name == 'merge_group' && 'Build & Test Shard 1 (Linux),Build & Test Shard 2 (Linux),Coverage Combine (Linux),APM Self-Check,NOTICE Drift Check,Build (Linux),Smoke Test (Linux),Integration Tests (Linux),Release Validation (Linux)' || 'Build & Test Shard 1 (Linux),Build & Test Shard 2 (Linux),APM Self-Check,NOTICE Drift Check' }}
# "Windows Compatibility Gate" is required at BOTH PR-time and
# merge-queue-time: it is the only Windows signal that runs
# before merge (build-release.yml's full Windows matrix only
# runs post-merge on push to main), so it must gate the class
# of Windows-only regressions fixed in microsoft/apm#2233
# rather than surfacing only after landing on main.
EXPECTED_CHECKS: ${{ github.event_name == 'merge_group' && 'Build & Test Shard 1 (Linux),Build & Test Shard 2 (Linux),Coverage Combine (Linux),Windows Compatibility Gate,APM Self-Check,NOTICE Drift Check,Build (Linux),Smoke Test (Linux),Integration Tests (Linux),Release Validation (Linux)' || 'Build & Test Shard 1 (Linux),Build & Test Shard 2 (Linux),Windows Compatibility Gate,APM Self-Check,NOTICE Drift Check' }}
# Poll budget: ci-integration.yml chains Build -> Smoke ->
# Integration (timeout 20m) -> Release Validation (timeout 20m).
# Theoretical worst case ~50m; observed today ~5m end-to-end.
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Four classes of Windows-only CI failures (CRLF baseline drift in JSON
reports, backslash-path authority-check diagnostics, bare-`git`-argv
subprocess resolution, and a WebSocket shutdown race) no longer slip
through Linux-only PR CI; each is now fixed under its canonical owner
(`atomic_write_text`, `.as_posix()`, `get_git_executable()`, a narrow
platform-scoped shutdown guard). (closes #2233; #2237)
- `apm update` now automatically repairs a locked dependency whose
materialized `apm_modules` cache is wholly absent -- including local
filesystem dependencies -- without prompting for ref-change consent or
Expand Down Expand Up @@ -87,6 +93,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- A focused Windows Compatibility Gate now runs at PR time on the
cross-platform contract test family, so Windows-only regressions are
caught before merge instead of surfacing only in the post-merge
`main` build. (#2233, #2237)
- Corporate proxy and internal-CA users can now use Python-based APM HTTPS paths
without per-shell TLS setup. APM verifies against the OS trust store through
`truststore` for `apm install`, the Python `llm` child runtime, and the frozen
Expand Down
10 changes: 5 additions & 5 deletions apm.lock.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
lockfile_version: '1'
generated_at: '2026-07-15T22:23:33.393882+00:00'
generated_at: '2026-07-16T10:55:54.310339+00:00'
apm_version: 0.25.0
dependencies:
- repo_url: _local/apm-issue-autopilot
Expand Down Expand Up @@ -228,7 +228,7 @@ dependencies:
.agents/skills/shepherd-driver/assets/pr-comment-templates.md: sha256:24fe0ec64286c7b39037a5e93acfa607a43dbb4763d653f3955a1c4d65d68882
.agents/skills/shepherd-driver/assets/shepherd-driver-prompt.md: sha256:48f5f9276f92984480dba4f0d54ecc5b612b6c72f313fd5222962dffd75ed0ed
.agents/skills/shepherd-driver/references/mergeability-gate.md: sha256:90bf5cd838d1025dd0fa223a0843be926cd509c85c91ef683cc9bfc98b5e8785
.agents/skills/shepherd-driver/scripts/owner_touch_gate.py: sha256:103535226722d4ae673657ab4123bffc23c821ea319e6b5d8eda7f96e729f8b6
.agents/skills/shepherd-driver/scripts/owner_touch_gate.py: sha256:f78a10e6a972046d212720372a1bf6971fd8cbadd0eb78c06c1a48f333fcd5b5
source: local
local_path: ../shepherd-driver
declaring_parent: ./packages/apm-issue-autopilot
Expand Down Expand Up @@ -2549,7 +2549,7 @@ deployments:
owners:
- local:packages/shepherd-driver
active_owner: local:packages/shepherd-driver
content_hash: sha256:103535226722d4ae673657ab4123bffc23c821ea319e6b5d8eda7f96e729f8b6
content_hash: sha256:f78a10e6a972046d212720372a1bf6971fd8cbadd0eb78c06c1a48f333fcd5b5
- kind: project-relative
target: agents
value: .agents/skills/supply-chain-security
Expand Down Expand Up @@ -2864,7 +2864,7 @@ deployments:
owners:
- .
active_owner: .
content_hash: sha256:ba0d1f1003960beb7a469d35ef724641f902841b4cb07049600c691b8c66afe4
content_hash: sha256:377c5f6f332cdce8c7aa2aa6096fc2cdef8596e0033a6b55d6f4d0b94a1740ac
local_deployed_files:
- .agents/skills/apm-spec-guardian
- .agents/skills/apm-spec-guardian/SKILL.md
Expand Down Expand Up @@ -3299,4 +3299,4 @@ local_deployed_file_hashes:
.github/instructions/integrators.instructions.md: sha256:19919cbd04031a8a09be8aa0860aa941640b8313df74439e7bfac31310aa1b13
.github/instructions/linting.instructions.md: sha256:8d31137f18842570bef6c93f8396587ac691e5fc973988bd865b008b0983f90d
.github/instructions/python.instructions.md: sha256:8acd2bb824eb8d70a1977dfef309a5a122b49320d22222dd8d3544ca08712bca
.github/instructions/tests.instructions.md: sha256:ba0d1f1003960beb7a469d35ef724641f902841b4cb07049600c691b8c66afe4
.github/instructions/tests.instructions.md: sha256:377c5f6f332cdce8c7aa2aa6096fc2cdef8596e0033a6b55d6f4d0b94a1740ac
Loading
Loading