Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- `apm install --frozen` no longer writes `apm.lock.yaml`, which req-lk-006
requires it to leave untouched. It previously deployed files and rewrote the
lockfile to claim them, so a committed lockfile that under-recorded the
deployed set was silently repaired in CI instead of being reported -- and the
files it omitted stayed outside `content-integrity`'s hash and hidden-Unicode
scanners. Frozen installs now fail and name the unrecorded paths. The check is
one-directional -- claims the install would drop (a `--target` filter,
`--only`, a removed dependency) are tolerated, as removed deps already were --
and `generated_at` / `apm_version` are excluded, so a newer CLI reading an
older lockfile is not treated as a rewrite. (#2379)
If CI starts failing on `--frozen` after upgrading, run `apm install`
locally and commit the updated `apm.lock.yaml`.
- Partial dependency updates preserve concrete deployment targets for refreshed and untouched packages, including skills under `.agents/skills/`, instead of demoting them to `legacy`. (#2924)
- Transient resolution-staging paths are shorter, so `apm install` no longer fails with `[WinError 206] The filename or extension is too long.` from a deep Windows checkout. The staging root drops from a full `uuid4().hex` to 12 hex characters and each per-destination slot from a full SHA-256 digest to 16, freeing 68 characters on every staged path. This is not a guarantee of arbitrary long-path support. Orphaned staging roots left by earlier versions are still cleaned up. (#2896)

Expand Down
3 changes: 2 additions & 1 deletion CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@
"keyword": "MUST",
"section": "5.5",
"status": "active",
"test_count": 1,
"test_count": 2,
"tests": [
"tests/spec_conformance/test_lockfile_reqs.py::TestFrozenInstallNeverWritesLockfile::test_frozen_install_withholds_the_write_and_fails_instead",
"tests/spec_conformance/test_lockfile_reqs.py::test_frozen_mcp_validation_fails_before_durable_mutation"
]
},
Expand Down
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ Repository-coordinate segments are case-insensitive for `github.com`, GitHub Ent
| [req-lk-003](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-003) | MUST | 5.2 | consumer | active | 2 | - |
| [req-lk-004](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-004) | MUST | 5.4 | consumer | active | 1 | - |
| [req-lk-005](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-005) | MUST | 5.5 | consumer | active | 2 | - |
| [req-lk-006](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-006) | MUST | 5.5 | consumer | active | 1 | - |
| [req-lk-006](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-006) | MUST | 5.5 | consumer | active | 2 | - |
| [req-lk-007](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-007) | SHOULD | 5.5 | consumer | active | 1 | - |
| [req-lk-008](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-008) | MUST | 5.6 | consumer | active | 1 | - |
| [req-lk-009](docs/src/content/docs/specs/openapm-v0.1.md#req-lk-009) | MUST | 5.6 | consumer | active | 1 | - |
Expand Down
1 change: 1 addition & 0 deletions docs/src/content/docs/integrations/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ See the [Pack a bundle guide](../../producer/pack-a-bundle/) for the full workfl

- **Pin APM version** in CI to avoid unexpected changes: `pip install apm-cli==0.22.0`
- **Commit `apm.lock.yaml`** so CI resolves the same dependency versions as local development
- **Install with `apm install --frozen` in CI** to verify the committed lockfile is actually current. It never writes `apm.lock.yaml` and exits `1`, naming the paths, if the install deploys files the lockfile does not record. A file the lockfile omits carries no recorded hash, which puts it outside the `content-integrity` check below -- so this is the gate that keeps that check's scope honest, and it replaces a hand-rolled `git diff --exit-code apm.lock.yaml` step. If CI fails on it, run `apm install` locally and commit the updated `apm.lock.yaml`.
- **Commit `.github/`, `.claude/`, `.cursor/`, `.opencode/`, and `.gemini/` deployed files** so contributors and cloud-based Copilot get agent context without running `apm install`
- **If using `apm compile`** (for Codex, Gemini instructions), run it in CI and fail the build if the output differs from what's committed
- **Use `GITHUB_APM_PAT`** for private dependencies; never use the default `GITHUB_TOKEN` for cross-repo access
7 changes: 4 additions & 3 deletions docs/src/content/docs/reference/cli/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ With no arguments it installs everything from `apm.yml`. With one or more `PACKA
| Flag | Default | Description |
|---|---|---|
| `--update` | off | Re-resolve dependencies to the latest version or Git ref allowed by `apm.yml` and rewrite `apm.lock.yaml`. Mutable Git refs must resolve against upstream; APM does not fall back to stale refs from the local bare Git cache. Mutually exclusive with `--frozen`. For interactive use with a confirmation prompt, use [`apm update`](../update/) instead. |
| `--frozen` | off | Lockfile-only install: refuse to resolve anything new and fail before any project, config, deployment, or cache write if `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP state. Mirrors `npm ci`. Mutually exclusive with `--update`, positional package additions, and `--mcp`. |
| `--frozen` | off | Lockfile-only install: never write `apm.lock.yaml`, refuse to resolve anything new, and fail before any project, config, deployment, or cache write if `apm.lock.yaml` is missing or out of sync with `apm.yml`, including MCP state. Also fails -- after deployment -- if the install deploys files the lockfile does not record. Mirrors `npm ci`. Mutually exclusive with `--update`, positional package additions, and `--mcp`. |
| `--dry-run` | off | Print the install plan without deployment writes. Positional packages and ref changes appear in the preview after validation but do not change an existing `apm.yml`. Project auto-bootstrap still keeps its new manifest and any explicit `--target` selection for the next run; global dry-run bootstrap uses temporary preview state and does not create `~/.apm`. The `-g --mcp` path creates no user manifest, lockfile, or runtime configuration. |
| `--force` | off | Overwrite locally-authored files on collision **and** bypass the security scan's critical-finding block. Does **not** suppress general install errors (any reported error still exits `1`, matching npm / pip / cargo) or select ref freshness. Add `--update` or `--refresh` to resolve mutable refs upstream; [`apm update`](../update/) does so with or without `--force`. Use only after independent verification. |
| `--verbose`, `-v` | off | Show per-file paths and full error context in the diagnostic summary. |
Expand Down Expand Up @@ -150,7 +150,8 @@ in `apm.yml`, then run `apm install` again.
- **Lockfile replay and Git ref freshness.** Plain and `--frozen` installs may trust `apm.lock.yaml` and the local Git cache, reusing the locked commit for unchanged Git dependencies across the full resolved graph. In contrast, `apm install --update`, `apm install --refresh`, [`apm update`](../update/) with or without `--force`, [`apm lock --update`](../lock/), and [`apm outdated`](../outdated/) establish mutable Git refs from upstream instead of accepting stale refs from a local bare Git cache. APM picks up upstream changes to a transitive package's `apm.yml` only when you regenerate the graph -- run `apm update` or `apm lock --update`. See the [lockfile specification](../../lockfile-spec/) for the replay contract.
- **Semver ranges on git deps.** `ref:` accepts semver ranges (`^1.2.0`, `~1.4`, `>=2.0 <3`, `1.5.x`) for git-source deps, including positional virtual-subdirectory references. APM runs `git ls-remote` against the dep, picks the highest tag matching the range, and pins the resolved tag plus commit SHA, version, and original constraint in `apm.lock.yaml`. Subsequent installs replay the lockfile without network; use `--update` (or change the manifest constraint) to re-resolve. See [manage dependencies](../../../consumer/manage-dependencies/#pin-a-semver-range) for the supported syntax.
- **No-op nudge.** When the lockfile is already satisfied and nothing needs deploying, install prints `[i] Run 'apm update' to check for newer versions.` so you know the silent success was not a missed refresh.
- **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml`. A missing lockfile, a direct dependency missing from it, or MCP config state that differs from `apm.yml` exits `1` before lockfile, target config, deployment, or cache mutation. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. This is a structural check, not a content check -- run `apm audit --ci` for hash verification.
- **Frozen mode.** With `--frozen`, install resolves only what is in `apm.lock.yaml` and **never writes it**. A missing lockfile, a direct dependency missing from it, or MCP config state that differs from `apm.yml` exits `1` before lockfile, target config, deployment, or cache mutation. Cold-cache installs (empty `apm_modules/`) with git `apm_package` deps are tolerated: MCP checks are skipped for absent package directories (the packages will be hydrated by the pipeline), and their MCP server configs are restored from the lockfile so no false drift is reported. Remote `claude_skill` dependencies declared at a repository root or subdirectory are also accepted from their locked type before materialization; once present, the lock type and detected skill shape must agree. Missing local paths still fail. See [`config-consistency`](../../baseline-checks/#config-consistency) for the full manifest rule. Run normal `apm install` to create or repair MCP-only lock state, then retry frozen mode. Add-style invocations (`apm install PACKAGE` and `apm install --mcp NAME`) are rejected because they mutate `apm.yml`. Orphan package lock entries are tolerated; local-path deps are skipped. Deployment still happens, as with `npm ci`. This is a structural check, not a content check -- run `apm audit --ci` for hash verification.
- **Frozen mode fails rather than rewriting.** If the install deploys files the committed `apm.lock.yaml` does not record, `--frozen` exits `1` and names those paths instead of silently updating the lockfile. A file no lockfile row claims carries no recorded hash, which leaves it outside `content-integrity`'s hash comparison *and* its hidden-Unicode scan, so this is the install-time half of that guarantee; `apm audit --ci` remains the on-disk content check. Unlike the structural checks above this one runs after the pipeline, so the deployed files are already on disk -- only the lockfile write was withheld. Recover by running `apm install` without `--frozen` and committing the updated `apm.lock.yaml`. The check is one-directional and matches the tolerance above: claims the install would *drop* (a `--target` filter, `--only`, a removed dependency) do not fail. `generated_at` and `apm_version` are excluded, so a newer CLI reading an older lockfile is not a rewrite.
- **Local `.apm/` deployment.** After dependencies are integrated, primitives in the project's own `.apm/` directory are deployed to the same targets. Local files win on collision. Skipped at `--global` and with `--only mcp`.
- **User-scope root context hint.** Compilation stays explicit. After `apm install -g`, targets with native user-scope instruction files pick up global instructions during install. Targets whose user-scope instruction surface is a root context file require [`apm compile --global`](../compile/#global-compilation); install prints a one-line `[i]` hint and writes no root context file.
- **OpenCode user scope.** `apm install -g --target opencode` deploys skills to
Expand Down Expand Up @@ -267,7 +268,7 @@ apm install owner/skill-bundle --skill '*' # reset to all skills
| Code | Meaning |
|---|---|
| `0` | Successful install or `--dry-run` preview. A preview does not certify real install success. For Agent Plugins v1 packages, a mixed install still succeeds when target exclusion skips one package but at least one other package deploys. |
| `1` | Install failure: security scan blocked a critical finding, auth error, manifest or required MCP/LSP config write error, dependency resolution error, Agent Plugins v1 target exclusion left no package deployed on a non-dry-run install, `--frozen` with a missing lockfile or a direct dependency absent from `apm.lock.yaml`, any reported install error (the diagnostic summary closes with `Installation failed with N error(s)`), or unhandled exception. `--force` does **not** suppress general install errors. The diagnostic summary names the cause. |
| `1` | Install failure: security scan blocked a critical finding, auth error, manifest or required MCP/LSP config write error, dependency resolution error, Agent Plugins v1 target exclusion left no package deployed on a non-dry-run install, `--frozen` with a missing lockfile, a direct dependency absent from `apm.lock.yaml`, or an install that deploys files `apm.lock.yaml` does not record, any reported install error (the diagnostic summary closes with `Installation failed with N error(s)`), or unhandled exception. `--force` does **not** suppress general install errors. The diagnostic summary names the cause. |
| `2` | Usage error: no deployment target detectable (no `--target`, no `target(s):` in `apm.yml`, no default target configured via `apm config set target <value>`, and no harness signal in the project), `--ssh` and `--https` both passed, `--frozen` and `--update` both passed, `--root` combined with `--global`, or a Click flag conflict. |

## Notes
Expand Down
46 changes: 46 additions & 0 deletions src/apm_cli/deps/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@

import logging
import os
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -39,6 +42,41 @@ class _ExistingLockfileUnset:

_EXISTING_LOCKFILE_UNSET = _ExistingLockfileUnset()

# req-lk-006 requires a frozen-install mode "in which the lockfile is never
# written or rewritten". An install reaches ``LockFile.write`` from eight
# call sites (dependency lockfile build, local bundles, target-contraction
# reconciliation, local-content persist, MCP and LSP integration, ...), so
# the suppression lives at the one chokepoint they all funnel through
# instead of as a flag threaded to each -- a new write site then inherits
# the guarantee rather than needing to remember it.
#
# Suppressed writes are *recorded*, not merely dropped: the caller compares
# them against the committed lockfile to decide whether the frozen install
# can honestly report success (see ``InstallService``).
#
# A ContextVar rather than a module global so the mode cannot leak across
# concurrent installs in one process (tests, programmatic callers).
_suppressed_writes: ContextVar[list[str] | None] = ContextVar(
"apm_suppressed_lockfile_writes", default=None
)


@contextmanager
def suppress_lockfile_writes() -> Iterator[list[str]]:
"""Record and discard every :meth:`LockFile.write` in this context.

Yields the list of serialised lockfiles that *would* have been written,
in call order. Serialised rather than held by reference so that later
mutation of the same in-memory :class:`LockFile` cannot rewrite
history: each entry is exactly the content that ``write`` withheld.
"""
attempts: list[str] = []
token = _suppressed_writes.set(attempts)
try:
yield attempts
finally:
_suppressed_writes.reset(token)


def installed_apm_version() -> str:
"""Return the running APM distribution version for lockfile metadata."""
Expand Down Expand Up @@ -983,6 +1021,10 @@ def write(
) -> None:
"""Write lock file to disk, preserving legacy timestamp behavior.

Inside :func:`suppress_lockfile_writes` (``apm install --frozen``,
req-lk-006) the serialised content is recorded for the caller and
the file on disk is left byte-for-byte untouched.

New lockfiles omit ``generated_at``. When the on-disk lockfile already
carries the field, keep it stable for semantic no-ops and refresh it for
substantive writes. This behavior should be changed to remove the legacy
Expand All @@ -992,6 +1034,10 @@ def write(
Callers that already loaded the destination can pass ``existing_lockfile``
to avoid parsing the same bytes again.
"""
suppressed = _suppressed_writes.get()
if suppressed is not None:
suppressed.append(self.to_yaml())
return
from ..utils.atomic_io import atomic_write_text
from ..utils.staging_guard import assert_no_staging_paths
from ..utils.yaml_io import load_yaml_str
Expand Down
29 changes: 23 additions & 6 deletions src/apm_cli/install/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,24 +73,41 @@ def __init__(self, message: str, *, diagnostic_context: str = ""):
class FrozenInstallError(RuntimeError):
"""Raised when ``apm install --frozen`` cannot proceed.

Two trigger conditions:
Three trigger conditions:

* Lockfile (``apm.lock.yaml``) is missing entirely.
* Lockfile is structurally out of sync with ``apm.yml`` -- a direct
dependency declared in the manifest has no entry in the lockfile.
In that case ``reasons`` carries one human-readable line per
missing dep so the renderer can list them.
* The install deploys files the committed lockfile does not record, so
honouring req-lk-006's "never written or rewritten" would have left
the project claiming less than it deploys -- and unclaimed files are
outside the audit's content checks. ``reasons`` names those paths.

The check is intentionally narrow: it flags the cases where running
install without ``--frozen`` would mutate the lockfile. Drift in
The first two are structural and run before the pipeline. Drift in
transitive deps or removed deps is allowed, mirroring how ``uv``
treats ``--frozen`` and how ``npm ci`` only enforces direct-deps
presence.
presence; the third follows the same rule and ignores claims the
install would *drop*.

``tip`` is the remediation line the CLI prints, carried on the error
because the two conditions have different remedies and both Click
handlers render this exception the same way.
"""

def __init__(self, message: str, *, reasons: list[str] | None = None):
DEFAULT_TIP = "Tip: run 'apm outdated' to see what changed, then 'apm update'."

def __init__(
self,
message: str,
*,
reasons: list[str] | None = None,
tip: str = DEFAULT_TIP,
):
super().__init__(message)
self.reasons = list(reasons or [])
self.tip = tip


def frozen_install_tip(error: FrozenInstallError) -> str:
Expand All @@ -104,7 +121,7 @@ def frozen_install_tip(error: FrozenInstallError) -> str:
)
if has_mcp_drift:
return "Tip: run 'apm install' without --frozen to create or repair MCP lock state."
return "Tip: run 'apm outdated' to see what changed, then 'apm update'."
return error.tip # the no-rewrite check carries its own remedy


class PolicyViolationError(RuntimeError):
Expand Down
Loading