Skip to content
Open
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]

### Fixed

- Partial dependency updates preserve concrete deployment targets for refreshed and untouched packages, including skills under `.agents/skills/`, instead of demoting them to `legacy`. (#2924)

### Added

- gh-aw's shared APM import now supports `token-source: github-token`; after consumers re-vendor the workflow, its read-only current-repository identity can fetch same-repository private packages, while `cascade` remains the default and cross-repository packages still require a dedicated token or GitHub App. (#2706)
Expand Down
5 changes: 5 additions & 0 deletions docs/src/content/docs/reference/cli/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ When every ref is already current but the locked `apm_modules/` cache is empty,

Pass one or more `PACKAGES` to refresh only those dependencies, or `-g/--global` to refresh the user-scope dependencies under `~/.apm/` instead of the current project. With these flags `apm update` is a strict superset of the deprecated [`apm deps update`](../deps/#apm-deps-update).

Partial updates retain deployment targets in `apm.lock.yaml` for both refreshed
and untouched packages, including skills deployed under the shared
`.agents/skills/` directory. A follow-up `apm install` is not needed to restore
target records.

This command refreshes dependencies, not the CLI. For CLI upgrades, use your
package manager (`brew upgrade apm` for Homebrew), or
[`apm self-update`](../self-update/) for standalone installs.
Expand Down
4 changes: 4 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@ Experimental flags MUST NOT gate security-critical behaviour (content scanning,

## Configuration and updates

Updating selected packages preserves deployment targets in `apm.lock.yaml` for
refreshed and untouched dependencies, including shared `.agents/skills/` paths.
No follow-up install is needed to restore those target records.

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `apm config` | Show current configuration | -- |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ def check_target_file_contraction(provider: FactsProvider) -> tuple[Violation, .


_REQUIRED_OWNER_CALLS = {
"src/apm_cli/install/phases/lockfile.py": ("merge_dependencies",),
"src/apm_cli/commands/prune.py": ("legacy_value", "reconcile_owner_references"),
"src/apm_cli/commands/audit.py": ("owner_reference_violations",),
"src/apm_cli/commands/uninstall/cli.py": ("cleanup_snapshot",),
Expand Down
25 changes: 25 additions & 0 deletions src/apm_cli/core/deployment_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,31 @@ def reconcile_owner_references(
),
)

@staticmethod
def merge_dependencies(
lockfile: LockFile,
updated: LockFile,
*,
project_root: Path,
diagnostics: DiagnosticCollector,
) -> None:
"""Merge processed dependencies without discarding concrete deployment locators."""
current = DeploymentLedgerCodec.from_lockfile(updated)
retained = DeploymentLedgerCodec.reconcile_owner_references(
lockfile,
excluded_dependency_keys=updated.dependencies,
project_root=project_root,
diagnostics=diagnostics,
)
# add_dependency invalidates the legacy projection. Capture both
# canonical ledgers first, then restore their reconciled records once.
for dependency in updated.dependencies.values():
lockfile.add_dependency(dependency)
DeploymentLedgerCodec.apply_to_lockfile(
DeploymentLedger(records={**retained.ledger.records, **current.records}),
lockfile,
)

@staticmethod
def from_lockfile(lockfile: LockFile) -> DeploymentLedger:
"""Read canonical rows or synthesize them from legacy ownership views."""
Expand Down
14 changes: 12 additions & 2 deletions src/apm_cli/install/phases/lockfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,10 @@ def _attach_marketplace_provenance(self, lockfile: LockFile) -> None:
lockfile.dependencies[dep_key].source_digest = prov.get("source_digest")

def _merge_existing(self, lockfile: LockFile) -> None:
# Partial operations merge untouched dependencies and their canonical
# deployment records together in _maybe_merge_partial.
if self.ctx.only_packages:
return
if self.ctx.existing_lockfile and not self.ctx.update_refs:
retained_orphans = getattr(self.ctx, "orphan_cleanup_retained", {})
for dep_key, dep in self.ctx.existing_lockfile.dependencies.items():
Expand Down Expand Up @@ -424,8 +428,14 @@ def _maybe_merge_partial(self, lockfile: LockFile, lockfile_path: Path, _LF: typ
if self.ctx.only_packages:
existing = _LF.read(lockfile_path)
if existing:
for key, dep in lockfile.dependencies.items(): # noqa: B007
existing.add_dependency(dep)
from apm_cli.core.deployment_ledger import DeploymentLedgerCodec

DeploymentLedgerCodec.merge_dependencies(
existing,
lockfile,
project_root=self.ctx.project_root,
diagnostics=self.ctx.diagnostics,
)
lockfile = existing
return lockfile

Expand Down
53 changes: 53 additions & 0 deletions tests/integration/test_virtual_package_lifecycle_matrix.py
Original file line number Diff line number Diff line change
Expand Up @@ -669,6 +669,59 @@ def test_virtual_package_lifecycle_matrix(
_assert_last_good_preserved(scenario.project, install_state)


@pytest.mark.parametrize("selected", ["skill", "instruction"])
def test_partial_update_preserves_declared_deployment_targets(
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
selected: str,
) -> None:
"""Updating one dependency retains target provenance for new and existing files."""
scenario = _create_scenario(tmp_path / selected)
installed = _invoke(
scenario,
monkeypatch,
("install", "--no-policy", "--parallel-downloads", "0"),
newline_domain="lf",
)
_assert_result(installed, 0, f"{selected}-initial-install")
before = load_yaml(scenario.project / "apm.lock.yaml")
assert {row["target"] for row in before["deployments"]} == {"copilot"}

if selected == "skill":
package = f"gitlab.example.invalid/acme/{_REPO_NAME}/{_SKILL_PATH}"
scenario.skill_source.write_bytes(_SKILL_BYTES + b"\nValidate authentication inputs.\n")
scenario.skill_source.with_name("reference.md").write_bytes(b"# Authentication reference\n")
else:
package = _VIRTUAL_FILE_DEPENDENCY.removesuffix("#main")
scenario.virtual_file_source.write_bytes(_VIRTUAL_FILE_BYTES + b"\nValidate inputs.\n")
new_commit = scenario.repositories.commit(
scenario.repository,
message=f"update {selected} guidance",
).sha

updated = _invoke(
scenario,
monkeypatch,
("update", package, "--yes", "--parallel-downloads", "0"),
newline_domain="lf",
)
_assert_result(updated, 0, f"{selected}-partial-update")
after = load_yaml(scenario.project / "apm.lock.yaml")
assert {row["target"] for row in after["deployments"]} == {"copilot"}
skill, instruction = _lock_dependencies(scenario.project)
changed, unchanged = (skill, instruction) if selected == "skill" else (instruction, skill)
assert changed["resolved_commit"] == new_commit
assert unchanged["resolved_commit"] == scenario.initial_commit
assert [row for row in after["deployments"] if package not in row["owners"]] == [
row for row in before["deployments"] if package not in row["owners"]
]
if selected == "skill":
reference = f".agents/skills/{_SKILL_NAME}/reference.md"
assert (scenario.project / reference).read_bytes() == b"# Authentication reference\n"
assert reference in changed["deployed_files"]
assert any(row["value"] == reference for row in after["deployments"])


@pytest.mark.lifecycle_merge_group
def test_virtual_throttle_fallback_records_fetch_head_sha_and_content_hash(
tmp_path: Path,
Expand Down
54 changes: 54 additions & 0 deletions tests/unit/core/test_deployment_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,60 @@ def test_codec_reconciles_owners_through_canonical_reconciler(tmp_path: Path) ->
assert lockfile.deployment_ledger.records[shared.key].active_owner == "beta"


def test_dependency_merge_retains_canonical_locators_and_surviving_owners(tmp_path: Path) -> None:
"""Partial replacement keeps opaque locators and removes only replaced ownership."""
prior = LockFile(
dependencies={
"alpha": LockedDependency(repo_url="alpha", resolved_commit="old-alpha"),
"beta": LockedDependency(repo_url="beta", resolved_commit="old-beta"),
}
)
shared = _locator(".agents/skills/shared/SKILL.md")
stale = _locator(".agents/skills/removed/SKILL.md")
copilot = _locator(".agents/skills/untouched/SKILL.md")
cursor = replace(copilot, target="cursor")
external = replace(copilot, kind=LocatorKind.TARGET_RELATIVE, value="skills/untouched")
service = replace(copilot, kind=LocatorKind.URI, target="mcp", value="server", runtime="vscode")
untouched = {
locator.key: _record(locator, owners=("beta",), active="beta")
for locator in (copilot, cursor, external)
}
untouched[service.key] = _record(service, owners=(".",), active=".")
DeploymentLedgerCodec.apply_to_lockfile(
DeploymentLedger(
records={
**untouched,
shared.key: _record(shared, owners=("beta", "alpha"), active="alpha"),
stale.key: _record(stale, owners=("alpha",), active="alpha"),
}
),
prior,
)
updated = LockFile(
dependencies={"alpha": LockedDependency(repo_url="alpha", resolved_commit="new-alpha")}
)
added = _locator(".agents/skills/new/reference.md")
current_record = _record(added, owners=("alpha",), active="alpha")
DeploymentLedgerCodec.apply_to_lockfile(
DeploymentLedger(records={added.key: current_record}), updated
)

DeploymentLedgerCodec.merge_dependencies(
prior, updated, project_root=tmp_path, diagnostics=DiagnosticCollector()
)

assert prior.deployment_ledger.records == {
**untouched,
shared.key: _record(shared, owners=("beta",), active="beta"),
added.key: current_record,
}
assert prior.dependencies["alpha"].resolved_commit == "new-alpha"
assert prior.dependencies["beta"].resolved_commit == "old-beta"
assert prior.dependencies["alpha"].deployed_files == [".agents/skills/new/reference.md"]
assert prior.mcp_target_servers == {"vscode": ["server"]}
assert LockFile.from_yaml(prior.to_yaml()).deployment_ledger == prior.deployment_ledger


def test_deployment_record_rejects_active_owner_outside_owners() -> None:
with pytest.raises(ValueError, match="active_owner must be present in owners"):
_record(_locator(), owners=("survivor",), active="removed")
Expand Down