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
1 change: 1 addition & 0 deletions .github/instructions/architecture.instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ semicolon-delimited, and specific to the file(s) that own the fact.
| CI audit scratch materialization | install/audit_replay.py (prepare_ci_audit_replay) | `src/apm_cli/install/audit_replay.py` |
| GitHub API throttle classification | deps/github_rate_limit.py | `src/apm_cli/deps/github_rate_limit.py` |
| Git ref freshness and cache eligibility | deps/tiered_ref_resolver.py (RefFreshnessPolicy) | `src/apm_cli/deps/tiered_ref_resolver.py` |
| Revision-pin update outcome (updates vs retained SHA pins) | deps/revision_pins.py (RevisionPinResolutionResult) | `src/apm_cli/deps/revision_pins.py` |
| Root vs dependency MCP declaration scope | integration/mcp_config_view.py (CurrentMcpConfigView) | `src/apm_cli/integration/mcp_config_view.py` |
| MCP package launcher selection and argv shape (container and non-container) | adapters/client/base.py (MCPClientAdapter) | `src/apm_cli/adapters/client/base.py` |
| Dependency CLI identifier parsing + uninstall selection | models/dependency/selection.py (via DependencyReference) | `src/apm_cli/models/dependency/selection.py` |
Expand Down
7 changes: 7 additions & 0 deletions docs/src/content/docs/reference/cli/update.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ apm update
- **No-op and service-only repair.** An accepted update with no dependency ref changes still reconciles missing MCP/LSP config. A manifest with only MCP/LSP dependencies also uses `apm update` as a configuration repair pass; `--dry-run` previews this without writing.
- **Empty caches are restored.** If the lockfile expects dependencies but `apm_modules/` has no materialized packages, an otherwise unchanged update restores the cache from the same refs and reports `Restored dependency cache without changing refs.` No confirmation is required because dependency refs do not move.

### Missing annotated revision-pin tags

When a revision-pinned dependency has no eligible annotated tag, APM warns and
retains its current SHA while continuing with unrelated updates. Transport
failures and malformed or invalid remote SHAs still fail the update before
writes.
Comment on lines +109 to +114

## Back-compat: `apm update` used to be the self-updater

In earlier releases, `apm update` self-updated the **APM CLI binary**. That behavior moved to [`apm self-update`](../self-update/) and `apm update` was repurposed as the dependency updater described above.
Expand Down
18 changes: 18 additions & 0 deletions scripts/lint-architecture-boundaries.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1599,6 +1599,24 @@ if [ "$mcp_runtime_variable_owner_defs" -ne 1 ] \
violations=$((violations + 1))
fi

echo "[*] AC34: revision-pin resolution outcome authority"
revision_pin_owner="src/apm_cli/deps/revision_pins.py"
revision_pin_command="src/apm_cli/commands/update.py"
revision_pin_result_defs=$(grep -rEc --include='*.py' \
'^class RevisionPinResolutionResult:' src/apm_cli | awk -F: '{sum += $2} END {print sum + 0}')
revision_pin_skip_defs=$(grep -rEc --include='*.py' \
'^class RevisionPinSkip:' src/apm_cli | awk -F: '{sum += $2} END {print sum + 0}')
if [ "$revision_pin_result_defs" -ne 1 ] \
|| [ "$revision_pin_skip_defs" -ne 1 ] \
|| ! grep -q '^def resolve_revision_pin_updates(' "$revision_pin_owner" \
|| ! grep -q 'return RevisionPinResolutionResult(' "$revision_pin_owner" \
|| ! grep -q 'resolution = resolve_revision_pin_updates(' "$revision_pin_command" \
|| ! grep -q 'for skipped in resolution.skips:' "$revision_pin_command" \
|| grep -q 'find_latest_annotated_tag(' "$revision_pin_command"; then
echo "[x] Revision-pin outcomes must route through RevisionPinResolutionResult"
violations=$((violations + 1))
fi

echo "[*] AC18: bootstrap project-name authority"
if ! uv run --extra dev python scripts/lint-bootstrap-project-name.py; then
echo "[x] Manifest bootstrap names must route through core/project_name.py"
Expand Down
18 changes: 13 additions & 5 deletions src/apm_cli/commands/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
from ..deps.revision_pins import (
RemoteRefDownloader,
RevisionPinResolutionError,
RevisionPinResolutionResult,
RevisionPinUpdate,
apply_revision_pin_updates,
render_revision_pin_update_plan,
Expand Down Expand Up @@ -146,7 +147,7 @@ def _resolve_and_stage_revision_pin_updates(
logger: InstallLogger,
downloader: RemoteRefDownloader | None = None,
max_workers: int = 4,
) -> list[RevisionPinUpdate]:
) -> RevisionPinResolutionResult:
"""Resolve SHA pins and stage their in-memory references for the plan.

The passed dependency references belong to a staged APMPackage copy, not to
Expand All @@ -164,7 +165,7 @@ def _resolve_and_stage_revision_pin_updates(
# independently re-resolves the freshly-written pin against upstream
# before downloading. Threading the SHA resolved here into install
# would collapse the authoritative-upstream fence.
updates = resolve_revision_pin_updates(
resolution = resolve_revision_pin_updates(
all_declared_deps,
downloader or _build_revision_pin_downloader(),
only_packages=only_set,
Expand All @@ -179,12 +180,18 @@ def _resolve_and_stage_revision_pin_updates(
logger.info("Run with --verbose for detailed diagnostics.")
sys.exit(1)

updates_by_key = {update.dep_key: update for update in updates}
for skipped in resolution.skips:
logger.warning(
f"Skipped revision pin for {skipped.display_name}: no annotated semver tag exists "
"upstream. Keeping the current SHA; publish an annotated release tag to refresh it."
)

updates_by_key = {update.dep_key: update for update in resolution.updates}
for dep_ref in all_declared_deps:
update = updates_by_key.get(dep_ref.get_unique_key())
if update is not None:
dep_ref.reference = update.new_sha
return updates
return resolution


def _annotate_lockfile_revision_tags(project_root: Path, updates: list[RevisionPinUpdate]) -> None:
Expand Down Expand Up @@ -629,12 +636,13 @@ def _run_dep_update(
_rich_info(f"Available: {', '.join(e.available)}", symbol="info")
sys.exit(1)

revision_pin_updates = _resolve_and_stage_revision_pin_updates(
revision_pin_resolution = _resolve_and_stage_revision_pin_updates(
all_declared_deps=all_declared_deps,
only_packages=only_packages,
logger=logger,
max_workers=parallel_downloads if parallel_downloads > 0 else 1,
)
revision_pin_updates = revision_pin_resolution.updates

plan_state = _UpdateRunState()

Expand Down
3 changes: 3 additions & 0 deletions src/apm_cli/deps/git_reference_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
is_ado_auth_failure_signal,
is_github_hostname,
)
from .git_remote_ops import validate_ls_remote_tag_output
from .github_rate_limit import raise_for_github_throttle

if TYPE_CHECKING:
Expand Down Expand Up @@ -306,6 +307,8 @@ def _public_github_op(
ado_bearer_also_failed = False

if outcome[0] == "ok":
if not include_heads:
validate_ls_remote_tag_output(outcome[1])
refs = host._parse_ls_remote_output(outcome[1])
return host._sort_remote_refs(refs)

Expand Down
40 changes: 40 additions & 0 deletions src/apm_cli/deps/git_remote_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,46 @@
from ..models.apm_package import GitReferenceType, RemoteRef


class RemoteRefParseError(RuntimeError):
"""Raised when git ls-remote output cannot be safely interpreted."""


_REMOTE_SHA_RE = re.compile(r"^[a-fA-F0-9]{40}$")


def validate_ls_remote_tag_output(output: str) -> None:
"""Reject malformed output from ``git ls-remote --tags``.

An empty response is valid for a repository without tags. Any nonempty
response must use the documented SHA-and-tag-ref wire format; otherwise a
revision-pin refresh must fail rather than treating transport corruption as
a missing release tag.
"""
plain_tags: set[str] = set()
peeled_tags: set[str] = set()
Comment on lines +28 to +29
for line in output.splitlines():
line = line.strip()
if not line:
continue
parts = line.split("\t", 1)
if len(parts) != 2:
raise RemoteRefParseError("Malformed git ls-remote tag output.")
Comment on lines +34 to +36
sha, refname = (part.strip() for part in parts)
if not _REMOTE_SHA_RE.fullmatch(sha) or not refname.startswith("refs/tags/"):
raise RemoteRefParseError("Malformed git ls-remote tag output.")
Comment on lines +38 to +39
raw_tag_name = refname.removeprefix("refs/tags/")
is_peeled = raw_tag_name.endswith("^{}")
tag_name = raw_tag_name[:-3] if is_peeled else raw_tag_name
if not tag_name or "^{}" in tag_name:
raise RemoteRefParseError("Malformed git ls-remote tag output.")
if is_peeled:
peeled_tags.add(tag_name)
else:
plain_tags.add(tag_name)
if peeled_tags - plain_tags:
raise RemoteRefParseError("Malformed git ls-remote tag output.")


def parse_ls_remote_output(output: str) -> list[RemoteRef]:
"""Parse ``git ls-remote --tags --heads`` output into RemoteRef objects.

Expand Down
52 changes: 45 additions & 7 deletions src/apm_cli/deps/revision_pins.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ class RevisionPinResolutionError(RuntimeError):
"""Raised when a SHA pin cannot be safely mapped to an annotated tag."""


class NoAnnotatedRevisionPinTagError(RevisionPinResolutionError):
"""Raised when an upstream has no eligible annotated semver tag."""


class RemoteRefDownloader(Protocol):
"""Downloader surface needed for authoritative remote tag checks."""

Expand Down Expand Up @@ -51,6 +55,23 @@ class RevisionPinUpdate:
display_name: str


@dataclass(frozen=True)
class RevisionPinSkip:
"""A SHA pin retained because upstream has no eligible replacement tag."""

dep_key: str
old_sha: str
display_name: str


@dataclass(frozen=True)
class RevisionPinResolutionResult:
"""Typed outcome of resolving every eligible revision pin."""

updates: tuple[RevisionPinUpdate, ...] = ()
skips: tuple[RevisionPinSkip, ...] = ()


def is_full_revision_pin(ref: str | None) -> bool:
"""Return True when *ref* is a full 40-character commit SHA."""
return bool(ref and _SHA_RE.match(ref.strip()))
Expand Down Expand Up @@ -107,7 +128,7 @@ def find_latest_annotated_tag(
break

if not candidates:
raise RevisionPinResolutionError(
raise NoAnnotatedRevisionPinTagError(
"No annotated tag found for revision-pinned dependency. "
"APM will not replace a SHA pin with a branch or lightweight tag."
)
Expand All @@ -122,8 +143,13 @@ def resolve_revision_pin_updates(
*,
only_packages: set[str] | None = None,
max_workers: int = 4,
) -> list[RevisionPinUpdate]:
"""Resolve direct SHA-pinned dependencies to latest annotated tag SHAs."""
) -> RevisionPinResolutionResult:
"""Resolve SHA pins, retaining pins that lack an eligible upstream tag.

A missing annotated semver tag is a nonfatal outcome: the current SHA stays
intact while unrelated dependencies can still update. Transport, malformed
remote data, and integrity failures remain fatal and are propagated.
"""
eligible: list[DependencyReference] = []
for dep_ref in dependencies:
dep_key = dep_ref.get_unique_key()
Expand All @@ -135,13 +161,22 @@ def resolve_revision_pin_updates(
eligible.append(dep_ref)

if not eligible:
return []
return RevisionPinResolutionResult()

def _resolve_one(dep_ref: DependencyReference) -> RevisionPinUpdate | None:
def _resolve_one(
dep_ref: DependencyReference,
) -> RevisionPinUpdate | RevisionPinSkip | None:
dep_key = dep_ref.get_unique_key()
old_sha = (dep_ref.reference or "").strip().lower()
remote_refs = downloader.list_remote_tag_refs(dep_ref)
latest = find_latest_annotated_tag(remote_refs, package_name=package_name(dep_ref))
try:
latest = find_latest_annotated_tag(remote_refs, package_name=package_name(dep_ref))
except NoAnnotatedRevisionPinTagError:
return RevisionPinSkip(
dep_key=dep_key,
old_sha=old_sha,
display_name=dep_key,
)
latest_sha = latest.commit_sha.strip().lower()
if not is_full_revision_pin(latest_sha):
raise RevisionPinResolutionError(
Expand All @@ -163,7 +198,10 @@ def _resolve_one(dep_ref: DependencyReference) -> RevisionPinUpdate | None:
else:
with ThreadPoolExecutor(max_workers=worker_count) as executor:
resolved = list(executor.map(_resolve_one, eligible))
return [update for update in resolved if update is not None]
return RevisionPinResolutionResult(
updates=tuple(item for item in resolved if isinstance(item, RevisionPinUpdate)),
skips=tuple(item for item in resolved if isinstance(item, RevisionPinSkip)),
)


def render_revision_pin_update_plan(updates: Iterable[RevisionPinUpdate]) -> str:
Expand Down
99 changes: 99 additions & 0 deletions tests/integration/test_architecture_authorities.py
Original file line number Diff line number Diff line change
Expand Up @@ -3731,6 +3731,105 @@ def test_mcp_runtime_argument_variable_guard_rejects_parallel_owner(tmp_path: Pa
assert "MCP runtime argument variables must route through MCPClientAdapter" in result.stdout


def test_revision_pin_resolution_has_single_owner() -> None:
"""Revision-pin skips and updates must come from one typed resolver outcome."""
root = Path(__file__).parents[2]
owner = (root / "src/apm_cli/deps/revision_pins.py").read_text(encoding="utf-8")
command = (root / "src/apm_cli/commands/update.py").read_text(encoding="utf-8")
guard = (root / "scripts/lint-architecture-boundaries.sh").read_text(encoding="utf-8")
owner_table = (root / ".github/instructions/architecture.instructions.md").read_text(
encoding="utf-8"
)

assert owner.count("class RevisionPinResolutionResult:") == 1
assert owner.count("class RevisionPinSkip:") == 1
assert owner.count("def resolve_revision_pin_updates(") == 1
assert "return RevisionPinResolutionResult(" in owner
assert "resolution = resolve_revision_pin_updates(" in command
assert "for skipped in resolution.skips:" in command
assert "find_latest_annotated_tag(" not in command
assert "Revision-pin update outcome (updates vs retained SHA pins)" in owner_table
assert "Revision-pin outcomes must route through RevisionPinResolutionResult" in guard


def test_revision_pin_resolution_guard_rejects_command_skip_bypass(tmp_path: Path) -> None:
"""The boundary lint rejects discarding resolver-provided retained pins."""
root = Path(__file__).parents[2]
sandbox = tmp_path / "repo"
shutil.copytree(
root,
sandbox,
ignore=shutil.ignore_patterns(
".git",
".venv",
".pytest_cache",
"__pycache__",
"build",
"dist",
"node_modules",
),
)
command_path = sandbox / "src/apm_cli/commands/update.py"
command_path.write_text(
command_path.read_text(encoding="utf-8").replace(
"for skipped in resolution.skips:",
"for skipped in ():",
1,
),
encoding="utf-8",
)

result = subprocess.run(
("bash", "scripts/lint-architecture-boundaries.sh"),
cwd=sandbox,
capture_output=True,
text=True,
check=False,
timeout=300,
)

assert result.returncode == 1
assert "Revision-pin outcomes must route through RevisionPinResolutionResult" in result.stdout


def test_revision_pin_resolution_guard_rejects_direct_tag_lookup_in_command(
tmp_path: Path,
) -> None:
"""The command cannot restore an independent annotated-tag decision."""
root = Path(__file__).parents[2]
sandbox = tmp_path / "repo"
shutil.copytree(
root,
sandbox,
ignore=shutil.ignore_patterns(
".git",
".venv",
".pytest_cache",
"__pycache__",
"build",
"dist",
"node_modules",
),
)
command_path = sandbox / "src/apm_cli/commands/update.py"
command_path.write_text(
command_path.read_text(encoding="utf-8") + "\n# find_latest_annotated_tag(\n",
encoding="utf-8",
)

result = subprocess.run(
("bash", "scripts/lint-architecture-boundaries.sh"),
cwd=sandbox,
capture_output=True,
text=True,
check=False,
timeout=300,
)

assert result.returncode == 1
assert "Revision-pin outcomes must route through RevisionPinResolutionResult" in result.stdout


def test_bootstrap_project_names_have_single_owner() -> None:
"""All generated manifest names must route through the shared resolver."""
root = Path(__file__).parents[2]
Expand Down
Loading
Loading