Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
8 changes: 7 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,17 @@ jobs:
run: apm audit --ci

# Gate B: regeneration drift (producer-side).
# NOTE: Once `apm-action` ships a CLI version that includes the
# default-on `apm audit` drift detection (issue #1071), this entire
# step becomes redundant -- Gate A above already catches the same
# divergence via install-replay. Keep this bash check until then as
# a defense-in-depth fallback.
#
# The action's `apm install` step re-integrated local .apm/ into
# .github/ via target auto-detection. If anything in the governed
# integration directories changed, someone edited the regenerated
# output without updating the canonical .apm/ source.
- name: Check APM integration drift
- name: Check APM integration drift (legacy bash fallback, see #1071)
run: |
if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/)" ]; then
echo "::error::APM integration files are out of date."
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **`apm audit` now detects integration drift by default.** Read-only cache-only install replay catches missed `apm install` runs, hand-edited deployed files, and orphaned files. Findings exposed in JSON (`drift` key) and SARIF (`apm/drift/<kind>` rules); in `--ci` mode they contribute to the exit code. Opt out with `--no-drift` (mutually exclusive with `--strip`/`--file`). See the [Drift Detection guide](docs/src/content/docs/guides/drift-detection.md) for details. (#1071, supersedes scope of #898)

### Fixed

- **Parallel subdir install race.** `apm install` no longer intermittently fails with `RuntimeError: Subdirectory '<path>' not found in repository` when multiple dependencies resolve to different subdirectories of the same `repo@ref`. The shared clone cache now stores subdir-agnostic bare clones and each consumer materializes its own working tree (mirrors the WS3 `GitCache` pattern). (#1135, fixes #1126)
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ apm install github/awesome-copilot/skills/review-and-refactor
- `apm_modules/` -- add to `.gitignore`. Rebuilt from the lockfile on install.

:::tip[Keeping deployed files in sync]
When you update `apm.yml`, re-run `apm install` and commit the changed `.github/`, `.claude/`, `.cursor/`, and `.gemini/` files. A [CI drift check](../../integrations/ci-cd/#verify-deployed-primitives) catches stale files automatically.
When you update `apm.yml`, re-run `apm install` and commit the changed `.github/`, `.claude/`, `.cursor/`, and `.gemini/` files. A [CI drift check](../../guides/drift-detection/) catches stale files automatically.
:::

:::note[Using Codex or Gemini?]
Expand Down
122 changes: 122 additions & 0 deletions docs/src/content/docs/guides/drift-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
---
title: Drift Detection
sidebar:
order: 7
---

`apm audit` runs **drift detection by default** so a stale working tree
cannot ship to production unnoticed. This page explains what drift means,
how the check works, and the escape hatch when you need to disable it.

## What is integration drift?

Integration drift is any divergence between what `apm install` would
deploy from your locked dependencies and what is actually on disk.
Three kinds matter:

| Kind | Meaning | Typical cause |
|---|---|---|
| `unintegrated` | A `.apm/` source file is committed but its deployed counterpart is missing | Forgot to re-run `apm install` after adding/editing local primitives |
| `modified` | A deployed file's content differs from what install would produce | Hand-edit to a regenerated file under `.github/`, `.claude/`, `.cursor/`, etc. |
| `orphaned` | A deployed file exists with no current source backing it | Removed a dependency or local primitive without re-running install |

All three previously required ad-hoc `git status --porcelain` scripts in
CI to detect. With drift detection, `apm audit` catches every case in
one read-only command -- nothing in your project, lockfile, or
`apm_modules/` is mutated.

## How it works

```mermaid
flowchart LR
A[apm audit] --> B[Read apm.lock.yaml<br/>+ cache contents]
B --> C[Replay install<br/>into scratch tmpdir]
C --> D[Diff scratch tree<br/>vs project]
D --> E[Render findings<br/>text / JSON / SARIF]
```

The replay is **cache-only** -- no network, no git fetch, no MCP
registry call. It will fail fast with `CacheMissError` if the lockfile
references content not present in the persistent cache (run
`apm install` once first).

False-positive guards normalize:

- Build-ID lines (e.g. APM-generated `<!-- Build ID: ... -->` markers).
- CRLF -> LF line endings (Windows checkouts of LF-canonical sources).
- UTF-8 BOM byte-order marks.

## Default behaviour and exit codes

| Mode | Drift findings | Exit code |
|---|---|---|
| `apm audit` | Reported in stdout | 0 (advisory only) |
| `apm audit --ci` | Reported and counted as failure | 1 |
| `apm audit --no-drift` | Skipped entirely | governed only by other checks |

In `--ci` mode drift findings are pooled with the seven baseline lockfile
checks (`lockfile-exists`, `ref-consistency`, etc.) -- a single
non-zero exit covers all of them.

## When to use `--no-drift`

The escape hatch exists for two legitimate cases:

1. **Tight inner loops** where you intentionally have local edits and
just want a content-only safety scan (`apm audit --no-drift -v`).
2. **Performance budgets** in matrix CI where you've already covered
drift in a single non-matrix job upstream.

Drift detection is also auto-skipped when `--strip` or `--file` is used;
both target a single payload and have nothing to diff against. Combining
`--no-drift` with `--strip` or `--file` is rejected with a usage error
(rather than silently picking one).

## Output formats

**Text (TTY default)** -- color-coded, one finding per line, grouped by kind.

**JSON** -- the audit report gains a top-level `drift` key:

```json
{
"report_format_version": "1.0",
"checks": [...],
"drift": [
{
"path": ".github/instructions/foo.md",
"kind": "modified",
"package": "<local>",
"inline_diff": "..."
}
]
}
```

**SARIF** -- findings are appended to `runs[0].results` with rule IDs
`apm/drift/modified`, `apm/drift/unintegrated`, `apm/drift/orphaned`,
ready to surface in GitHub code-scanning.

## CI integration

The recommended CI gate is now a single line:

```yaml
- run: apm audit --ci
```

This subsumes the legacy bash workaround:

```yaml
# Legacy -- no longer needed once apm-action ships with drift support
- run: |
if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/)" ]; then
exit 1
fi
```

For org-policy enforcement, combine with `--policy org` -- drift
detection composes orthogonally with the 17 audit-only policy checks.

See also: [CI Policy Enforcement](../ci-policy-setup/),
[Governance Guide](../../enterprise/governance-guide/).
20 changes: 10 additions & 10 deletions docs/src/content/docs/integrations/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,22 +60,22 @@ This step is not needed if your team only uses GitHub Copilot and Claude, which

### Verify Deployed Primitives

To ensure `.github/`, `.claude/`, `.cursor/`, `.opencode/`, and `.gemini/` integration files stay in sync with `apm.yml`, add a drift check:
`apm audit --ci` catches integration drift by default -- no separate
`git status` step required:

```yaml
- name: Check APM integration drift
run: |
apm install
if [ -n "$(git status --porcelain -- .github/ .claude/ .cursor/ .opencode/ .gemini/)" ]; then
echo "APM integration files are out of date. Run 'apm install' and commit."
exit 1
fi
- name: Audit + drift check
run: apm audit --ci
```

This catches cases where a developer updates `apm.yml` but forgets to re-run `apm install`.
This single command runs the seven baseline lockfile checks AND replays
the install pipeline into a scratch tree to detect missed `apm install`
runs, hand-edited deployed files, and orphaned files. See the
[Drift Detection guide](../../guides/drift-detection/) for details and
opt-out (`--no-drift`).

:::tip[We dogfood this]
APM's own repo uses the `APM Self-Check` job in [`microsoft/apm`'s `ci.yml`](https://github.com/microsoft/apm/blob/main/.github/workflows/ci.yml) as a reference implementation for installing APM, running CI validation commands such as `apm audit --ci`, and checking for drift with `git status --porcelain`. Use it as a practical example when wiring these checks into your own workflow.
APM's own repo uses the `APM Self-Check` job in [`microsoft/apm`'s `ci.yml`](https://github.com/microsoft/apm/blob/main/.github/workflows/ci.yml) as a reference implementation for installing APM and running `apm audit --ci`. Use it as a practical example when wiring these checks into your own workflow.
:::

## Azure Pipelines
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,11 +459,12 @@ apm audit [PACKAGE] [OPTIONS]
- `-v, --verbose` - Show info-level findings and file details
- `-f, --format [text|json|sarif|markdown]` - Output format: `text` (default), `json` (machine-readable), `sarif` (GitHub Code Scanning), `markdown` (step summaries). Cannot be combined with `--strip` or `--dry-run`.
- `-o, --output PATH` - Write report to file. Auto-detects format from extension (`.sarif`, `.sarif.json` → SARIF; `.json` → JSON; `.md` → Markdown) when `--format` is not specified.
- `--ci` - Run lockfile consistency checks for CI/CD gates. Exit 0 if clean, 1 if violations found. Auto-discovers org policy from the org `.github` repo unless `--no-policy` is set. Runs the 7 baseline checks: lockfile presence, ref consistency, deployed files present, no orphaned packages, MCP config consistency, content integrity (Unicode + hash drift on every deployed file including local content), includes consent (advisory).
- `--ci` - Run lockfile consistency checks for CI/CD gates. Exit 0 if clean, 1 if violations found. Auto-discovers org policy from the org `.github` repo unless `--no-policy` is set. Runs the 7 baseline checks: lockfile presence, ref consistency, deployed files present, no orphaned packages, MCP config consistency, content integrity (Unicode + hash drift on every deployed file including local content), includes consent (advisory). Integration drift detection runs alongside the baseline checks and contributes to the exit code (use `--no-drift` to opt out).
- `--policy SOURCE` - *(Experimental)* Policy source. Accepts: `org` (auto-discover from your project's git remote), `owner/repo` (defaults to github.com), an `https://` URL, or a local file path. Used with `--ci` for policy checks. Without this flag, `--ci` auto-discovers.
- `--no-policy` - Skip policy discovery and enforcement entirely. Equivalent to `APM_POLICY_DISABLE=1`.
- `--no-cache` - Force fresh policy fetch (skip cache). Only relevant with policy discovery active.
- `--no-fail-fast` - Run all checks even after a failure. By default, CI mode stops at the first failing check to save time.
- `--no-drift` - Skip integration drift detection. Drift detection is on by default (whole-project audit only) and replays the install pipeline into a scratch tree to catch missed `apm install` runs, hand-edited deployed files, and orphaned files. Mutually exclusive with `--strip`/`--file`. See the [Drift Detection guide](../../guides/drift-detection/).

**Examples:**
```bash
Expand Down
4 changes: 3 additions & 1 deletion packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm audit [PKG]` | Scan for security issues | `--file PATH`, `--strip`, `--dry-run`, `-v`, `-f [text\|json\|sarif\|md]`, `-o PATH`, `--ci`, `--policy SOURCE`, `--no-cache`, `--no-fail-fast` |
| `apm audit [PKG]` | Scan for security issues + detect integration drift | `--file PATH`, `--strip`, `--dry-run`, `-v`, `-f [text\|json\|sarif\|md]`, `-o PATH`, `--ci`, `--policy SOURCE`, `--no-cache`, `--no-fail-fast`, `--no-drift` |

`apm audit` runs **drift detection by default** (issue #1071). It replays `apm install` cache-only into a temporary scratch tree and diffs the result against your working tree. Catches three failure modes: (1) `.apm/` source added without re-running `apm install`, (2) hand-edits to deployed files that diverge from canonical source, (3) orphan files left after their source was removed. The scan is read-only -- never writes to your project, lockfile, or `apm_modules/`. Build IDs, CRLF line endings, and BOMs are normalized away so they cannot trigger false positives. Use `--no-drift` to opt out (e.g. fast inner loops); the flag is mutually exclusive with `--strip`/`--file`. In `--ci` mode drift findings produce exit code 1 alongside the seven baseline lockfile checks. Drift output is integrated into JSON (top-level `drift` key) and SARIF (rule IDs `apm/drift/<kind>` where kind is `modified`/`unintegrated`/`orphaned`).

## Distribution

Expand Down
28 changes: 28 additions & 0 deletions scripts/test-integration.sh
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,34 @@ run_e2e_tests() {
exit 1
fi

# Run drift-detection integration tests -- offline, no tokens needed
# Guards `apm audit` drift replay (Phase D) across all 9 drift cases,
# multi-target, --no-drift opt-out, and false-positive guards
# (CRLF, BOM, Build ID line). Pinning these tests prevents silent
# regression of the drift contract.
log_info "Running drift detection integration tests..."
echo "Command: pytest tests/integration/test_drift_check.py -v -s --tb=short"

if pytest tests/integration/test_drift_check.py -v -s --tb=short; then
log_success "Drift detection integration tests passed!"
else
log_error "Drift detection integration tests failed!"
exit 1
fi

# Run drift-detection E2E tests -- offline, no tokens needed
# Verifies the no-write contract, air-gap proof, performance smoke,
# and JSON/SARIF output shapes for the `apm audit` drift surface.
log_info "Running drift detection E2E tests..."
echo "Command: pytest tests/integration/test_drift_check_e2e.py -v -s --tb=short"

if pytest tests/integration/test_drift_check_e2e.py -v -s --tb=short; then
log_success "Drift detection E2E tests passed!"
else
log_error "Drift detection E2E tests failed!"
exit 1
fi

log_success "All integration test suites completed successfully!"


Expand Down
Loading
Loading