Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
bbe32b3
fix: install versioned marketplace package directories
Pybsama Oct 3, 2026
b9b01e0
docs: explain versioned marketplace directory installs
Pybsama Oct 3, 2026
cd5e8d3
docs: scope directory probe guidance to Git sources
Pybsama Oct 3, 2026
3fe2734
fix: validate GitHub directory response shapes before fallback
Pybsama Oct 3, 2026
5550161
Merge current main into versioned marketplace repair
danielmeppiel Oct 15, 2026
01b0292
Clarify versioned marketplace identity and reference guidance
danielmeppiel Oct 15, 2026
fdc20cc
Merge current main while preserving marketplace repair scope
danielmeppiel Oct 15, 2026
b63f211
Merge current main CI action updates before verification
danielmeppiel Oct 15, 2026
4087d75
Merge current self-audit conformance fix before final checks
danielmeppiel Oct 15, 2026
ead915f
Merge current prune casing fix before conformance recovery
danielmeppiel Oct 15, 2026
01da127
test: bind versioned marketplace lifecycle to existing conformance rule
danielmeppiel Oct 15, 2026
95e5ddc
Merge current main manifestless marketplace certification
danielmeppiel Oct 15, 2026
a033a75
Merge current main and preserve both marketplace changelog entries
danielmeppiel Oct 16, 2026
e557cb2
Merge latest main before exact-head conflict-recovery gates
danielmeppiel Oct 16, 2026
6564e2c
Merge local provenance recovery and retain both release entries
danielmeppiel Oct 16, 2026
8fe8193
Merge final batch main preserving both owner mutation regressions
danielmeppiel Oct 16, 2026
6c441f8
Merge release main preserving pending marketplace directory fix
danielmeppiel Oct 19, 2026
904a02f
Merge current main preserving both pending release entries
danielmeppiel Oct 19, 2026
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
6 changes: 3 additions & 3 deletions .apm/architecture/owners/install-deployment.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@
},
{
"id": "prospective-dry-run-install-plan",
"decision": "Prospective dry-run install plan",
"owner": "install/dry_run_plan.py (ProspectiveInstallPlan)",
"selectors": ["src/apm_cli/install/dry_run_plan.py"],
"decision": "Prospective dry-run install plan and interpreted package selection",
"owner": "install/dry_run_plan.py (ProspectiveInstallPlan) delegates selector identity to install/package_selection.py",
"selectors": ["src/apm_cli/install/dry_run_plan.py", "src/apm_cli/install/package_selection.py"],
"guards": ["install-deployment-prospective-dry-run-plan"]
},
{
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Git marketplace installs accept versioned plugin directories while rejecting file, symlink, and submodule responses during directory validation. -- by @Pybsama (#3156)
- `apm install` and `apm update` no longer fail with `Object of type mappingproxy is not JSON serializable` when a hook carries nested metadata such as `env`; Claude, Codex, Gemini and Antigravity hook files keep that metadata as plain JSON. (by @abhishekacharya200, with regression coverage from @MohammedAlkindi in #3199, closes #3167) (#3211)

## [0.34.0] - 2026-10-16
Expand Down
6 changes: 4 additions & 2 deletions CONFORMANCE.json
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,11 @@
"keyword": "MUST",
"section": "4.3.3",
"status": "active",
"test_count": 1,
"test_count": 3,
"tests": [
"tests/spec_conformance/test_manifest_reqs.py::test_consumer_supports_pinned_version"
"tests/spec_conformance/test_manifest_reqs.py::test_consumer_supports_pinned_version",
"tests/spec_conformance/test_marketplace_directory_reqs.py::test_versioned_marketplace_directory_installs_and_replays_as_subdirectory[1.2.3]",
"tests/spec_conformance/test_marketplace_directory_reqs.py::test_versioned_marketplace_directory_installs_and_replays_as_subdirectory[2026.9.3]"
]
},
{
Expand Down
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ APM claims the proposed deployed-prompt-audit capability (req-pl-019, req-pl-020
| [req-mf-005](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-005) | MUST | 4.2.1 | producer | active | 1 | - |
| [req-mf-006](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-006) | MUST | 4.1 | consumer | active | 1 | - |
| [req-mf-007](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-007) | MUST | 4.3.1 | consumer | active | 1 | - |
| [req-mf-008](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-008) | MUST | 4.3.3 | consumer | active | 1 | - |
| [req-mf-008](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-008) | MUST | 4.3.3 | consumer | active | 3 | - |
| [req-mf-009](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-009) | MUST | 4.3.4 | consumer | active | 1 | - |
| [req-mf-010](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-010) | MUST | 4.3.2 | consumer | active | 1 | - |
| [req-mf-011](docs/src/content/docs/specs/openapm-v0.1.md#req-mf-011) | MUST | 4.3.2 | consumer | active | 1 | - |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ identity; bare cross-repository entries retain their normal dependency defaults.
Invalid URLs and unsafe subdirectory
paths fail before manifest, lockfile, or deployment writes.

Catalog paths can point to versioned directories generated by `apm pack`, such
as `plugins/my-plugin-1.2.3`. APM keeps their repository/path coordinates through
installation and `--dry-run` instead of treating the version suffix as a file
extension. Directory content and path checks still apply. Git directory probes
reject files, symbolic links, and submodules.

Use `apm install pkg@catalog#v1.0.1` for a literal Git tag. For a range, declare
the marketplace dependency in `apm.yml`:

Expand Down
5 changes: 5 additions & 0 deletions packages/apm-guide/.apm/skills/apm-usage/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ lowercase before constructing the package-registry path.

### Install validation chain (virtual subdirectory packages)

Marketplace installation supports versioned directories produced by `apm pack`,
such as `plugins/my-plugin-1.2.3`, including positional `--dry-run` previews.
Directory content and path validation still apply. Git directory probes reject
files, symbolic links, and submodules.

`apm install` validates subdirectory packages (`owner/repo/path#ref`) before writing to `apm.yml` using the same credential chain as the actual install. Git-source semver ranges (for example, `owner/repo/path#^1.2.0`) defer raw-ref validation to semver tag resolution; registry-routed dependencies retain registry version validation. See [Authentication > Install validation chain](../authentication/) for the full probe sequence and troubleshooting.

When a default registry is configured, plain shorthand deps (`owner/repo#<ref>`) bypass the GitHub probe. `apm install` requires a version selector before writing to `apm.yml`; deps with no `#<ref>` at all are rejected. Semver selectors (`1.0.0`, `^1.2.3`) use range matching; non-semver selectors (`stable`, `v1.4.2`, any opaque label) are matched exactly against the registry's published versions.
Expand Down
11 changes: 8 additions & 3 deletions packages/apm-guide/.apm/skills/apm-usage/dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,9 +390,14 @@ Virtual packages reference a subset of a repository.
| Type | Detection rule | Example |
|------|---------------|---------|
| File | Ends in `.prompt.md`, `.instructions.md`, or `.agent.md` | `owner/repo/prompts/review.prompt.md` |
| Subdirectory | Does not match a file extension above | `owner/repo/skills/security` |

Classification is by extension only. A path like `owner/repo/collections/security` (no extension) is a Subdirectory; the actual shape -- APM package (incl. dep-only `apm.yml` with no `.apm/`), skill bundle, or plugin -- is resolved at fetch time by probing for `apm.yml`.
| Subdirectory | Shorthand path has no file extension, or an explicit `git`/`path` reference identifies a directory | `owner/repo/skills/security` |

Shorthand rejects unsupported file-like suffixes. Git-hosted marketplaces can
preserve dotted directories such as `plugins/my-plugin-1.2.3` through explicit
`git`/`path` coordinates; do not rewrite them as shorthand. Directory and
package-content validation still apply. The actual package shape -- APM package
(including dep-only `apm.yml`), skill bundle, or plugin -- is resolved at fetch
time. See [install validation](commands.md#install-validation-chain-virtual-subdirectory-packages).

**Gitea and Gogs (self-hosted or vendor-hosted):** virtual packages resolve via the host's `/{owner}/{repo}/raw/{ref}/{path}` URL first, then fall back to the Contents API (v1 native, v3 Gogs-compat). Direct GitLab nested-group repos (`group/subgroup/repo`) require the object form (`git: <full-url>`, `path: <virtual>`) -- shorthand is ambiguous on >2-segment paths. **Exception:** when the dep routes through a registry proxy (explicit `host/artifactory/<key>/...` FQDN, or bare shorthand under `PROXY_REGISTRY_URL` + `PROXY_REGISTRY_ONLY=1`), the install-time boundary probe HEAD-walks the candidate splits against the proxy and locks in the first one whose archive responds, so nested-group shorthand works without the object form (#1472).

Expand Down
47 changes: 45 additions & 2 deletions scripts/architecture_linter/checks/install_dry_run_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import ast
import re

from scripts.architecture_linter.checks.install_deployment_shared import (
Expand All @@ -19,6 +20,35 @@
_OWNER = "src/apm_cli/install/dry_run_plan.py"
_COMMAND = "src/apm_cli/commands/install.py"
_RENDERER = "src/apm_cli/install/presentation/dry_run.py"
_SELECTION_OWNER = "src/apm_cli/install/package_selection.py"
_RESOLVE = "src/apm_cli/install/phases/resolve.py"


def _selection_uses_interpreted_identity(provider: FactsProvider) -> bool:
"""Install and preview must preserve structured references through one owner."""
for path, function_name in (
(_OWNER, "ProspectiveInstallPlan.from_apm_package"),
(_RESOLVE, "_apply_only_filter"),
):
index = provider.tree_index(path)
function = index.function(function_name) if index is not None else None
if function is None:
return False
calls = [node for node in ast.walk(function) if isinstance(node, ast.Call)]
if not any(
isinstance(call.func, ast.Name) and call.func.id == "selected_dependency_identity"
for call in calls
):
return False
if any(
isinstance(call.func, ast.Attribute)
and isinstance(call.func.value, ast.Name)
and call.func.value.id == "DependencyReference"
and call.func.attr == "parse"
for call in calls
):
return False
return True


def check_prospective_dry_run_plan(provider: FactsProvider) -> tuple[Violation, ...]:
Expand All @@ -27,7 +57,8 @@ def check_prospective_dry_run_plan(provider: FactsProvider) -> tuple[Violation,
owner, owner_fail = _facts_for(provider, _OWNER, rule_id)
command, command_fail = _facts_for(provider, _COMMAND, rule_id)
renderer, renderer_fail = _facts_for(provider, _RENDERER, rule_id)
failures = list(owner_fail) + list(command_fail) + list(renderer_fail)
selection, selection_fail = _facts_for(provider, _SELECTION_OWNER, rule_id)
failures = list(owner_fail) + list(command_fail) + list(renderer_fail) + list(selection_fail)
if failures:
return tuple(failures)

Expand All @@ -41,8 +72,20 @@ def check_prospective_dry_run_plan(provider: FactsProvider) -> tuple[Violation,
message="ProspectiveInstallPlan must remain the sole dry-run preview owner",
respect_exempt=False,
)
identity_pattern = re.compile(r"^def selected_dependency_identity\(")
duplicates += _duplicate_definition_lines(
provider,
rule_id=rule_id,
prefix=_SRC_PREFIX,
pattern=identity_pattern,
owner=_SELECTION_OWNER,
message="Install selector identity must remain owned by package_selection",
respect_exempt=False,
)
contract_holds = (
_count_re(owner, class_pattern) == 1
and _count_re(selection, identity_pattern) == 1
and _selection_uses_interpreted_identity(provider)
and _present(owner, "def from_apm_package(")
and _present(owner, "def with_allowed_lsp_dependencies(")
and _present(owner, "selected_apm_dependencies=selected_apm_dependencies")
Expand Down Expand Up @@ -74,7 +117,7 @@ def check_prospective_dry_run_plan(provider: FactsProvider) -> tuple[Violation,
rule_id,
_OWNER,
"Dry-run dependencies, selection, checks, rendering, and counts must route "
"through ProspectiveInstallPlan",
"through ProspectiveInstallPlan and the shared package-selection identity owner",
),
*duplicates,
)
Expand Down
27 changes: 20 additions & 7 deletions src/apm_cli/deps/github_downloader_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,10 +238,11 @@ def _directory_exists_at_ref(

Uses the default ``Accept: application/vnd.github+json`` so the
endpoint returns the directory listing for directories (and file
metadata for files). A 200 means the path resolves at the ref,
which is what install needs.
metadata for files). Only an array of entry objects confirms a
directory; file, symlink, and submodule objects do not.

Returns ``True`` on 200; ``False`` on 404 or any error. Only
Returns ``True`` for a directory listing, including an empty array;
``False`` for non-directory content, 404, or any error. Only
implemented for github.com / GHE; non-GitHub hosts return ``False``
and rely on the marker-file probes above.
"""
Expand Down Expand Up @@ -287,6 +288,16 @@ def _request(token: str | None, _git_env: dict[str, str]) -> bool:
)
raise_for_github_throttle(response, host)
if response.status_code == 200:
try:
entries = response.json()
except ValueError:
log(f" [x] {path}@{ref} (invalid Contents API JSON)")
return False
if not isinstance(entries, list) or not all(
isinstance(entry, dict) for entry in entries
):
log(f" [!] {path}@{ref} (not a directory listing)")
return False
log(f" [+] {path}@{ref} (directory)")
return True
response.raise_for_status()
Expand Down Expand Up @@ -730,7 +741,7 @@ def _path_exists_in_tree_at_ref(
log: Callable[[str], None],
winning_attempt: AttemptSpec,
) -> bool:
"""Confirm ``vpath`` exists at ``ref`` via shallow fetch + ``ls-tree``.
"""Confirm ``vpath`` is a directory at ``ref`` via shallow fetch + ``ls-tree``.

Closes the fail-open hole in ``_ref_exists_via_ls_remote``: knowing
that the ref exists is not the same as knowing the subdirectory
Expand All @@ -748,7 +759,7 @@ def _path_exists_in_tree_at_ref(

Returns:
True iff the shallow fetch succeeded AND ``ls-tree`` reported
at least one entry for ``vpath`` at the resolved ref.
a directory entry for ``vpath`` at the resolved ref.
"""
label, url, env = winning_attempt

Expand Down Expand Up @@ -804,7 +815,7 @@ def _path_exists_in_tree_at_ref(

try:
result = subprocess.run(
[git_exe, "--git-dir", str(bare), "ls-tree", "FETCH_HEAD", vpath],
[git_exe, "--git-dir", str(bare), "ls-tree", "-d", "FETCH_HEAD", vpath],
check=True,
capture_output=True,
text=True,
Expand All @@ -816,7 +827,9 @@ def _path_exists_in_tree_at_ref(
log(f" [x] ls-tree failed via {label}: {error}")
return False

if output and output.strip():
# ``-d`` also includes gitlinks (160000 commit), which cannot be
# materialized as plugin content without following another repository.
if output and output.split(maxsplit=2)[:2] == ["040000", "tree"]:
Comment thread
danielmeppiel marked this conversation as resolved.
Resolved
log(f" [+] {vpath}@{ref} present in tree")
return True
log(f" [!] {vpath} not present in tree at {ref}")
Expand Down
7 changes: 5 additions & 2 deletions src/apm_cli/install/dry_run_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from dataclasses import dataclass, replace
from typing import Any

from apm_cli.install.package_selection import selected_dependency_identity
from apm_cli.models.dependency.reference import DependencyReference
from apm_cli.security.executables import filter_lsp_by_allow_executables

Expand Down Expand Up @@ -41,7 +42,8 @@ def from_apm_package(
selected_apm_dependencies = all_apm_dependencies
if only_packages is not None:
selected_identities = {
DependencyReference.parse(package).get_identity() for package in only_packages
selected_dependency_identity(package, all_apm_dependencies)
for package in only_packages
}
selected_apm_dependencies = tuple(
dependency
Expand All @@ -60,7 +62,8 @@ def from_apm_package(
only_packages=tuple(only_packages) if only_packages is not None else None,
lsp_dependencies=tuple(apm_package.get_lsp_dependencies()),
updated_apm_identities=frozenset(
DependencyReference.parse(package).get_identity() for package in updated_packages
selected_dependency_identity(package, all_apm_dependencies)
for package in updated_packages
),
)

Expand Down
15 changes: 13 additions & 2 deletions src/apm_cli/install/package_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,27 @@

from __future__ import annotations

from collections.abc import Sequence
from typing import TYPE_CHECKING

from apm_cli.models.dependency.reference import DependencyReference

if TYPE_CHECKING:
from apm_cli.core.command_logger import _ValidationOutcome


def selected_dependency_identity(package: str, dependencies: Sequence[DependencyReference]) -> str:
"""Resolve a selector using interpreted references before shorthand parsing."""
for dependency in dependencies:
# Explicit git+path coordinates can contain dotted bundle directories
# whose canonical display is not a valid shorthand file reference.
if dependency.to_canonical() == package:
return dependency.get_identity()
return DependencyReference.parse(package).get_identity()


def existing_dependency_identities(current_dependencies: list[object]) -> set[str]:
"""Return canonical identities for every parseable manifest dependency."""
from apm_cli.models.apm_package import DependencyReference

identities: set[str] = set()
for entry in current_dependencies:
try:
Expand Down
9 changes: 4 additions & 5 deletions src/apm_cli/install/phases/resolve.py
Original file line number Diff line number Diff line change
Expand Up @@ -928,23 +928,22 @@ def _apply_only_filter(ctx: InstallContext) -> None:
# ------------------------------------------------------------------
# 7. --only filtering
# ------------------------------------------------------------------
from apm_cli.models.apm_package import DependencyReference
from apm_cli.install.package_selection import selected_dependency_identity

# Build identity set from user-supplied package specs.
# Accepts any input form: git URLs, FQDN, shorthand.
tree = ctx.dependency_graph.dependency_tree
resolved_refs = [node.dependency_ref for node in tree.nodes.values()]
only_identities: builtins.set = builtins.set()
for p in ctx.only_packages:
try:
ref = DependencyReference.parse(p)
only_identities.add(ref.get_identity())
only_identities.add(selected_dependency_identity(p, resolved_refs))
except Exception:
only_identities.add(p)

# Expand the set to include transitive descendants of the
# requested packages so their MCP servers, primitives, etc.
# are correctly installed and written to the lockfile.
tree = ctx.dependency_graph.dependency_tree

def _collect_descendants(node: object, visited: builtins.set | None = None) -> None:
"""Walk the tree and add every child identity (cycle-safe)."""
if visited is None:
Expand Down
18 changes: 12 additions & 6 deletions src/apm_cli/marketplace/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
from ..deps.transport_selection import initial_transport_scheme
from ..models.dependency.host_virtual import dependency_repository_owner
from ..models.dependency.reference import DependencyReference
from ..models.validation import InvalidVirtualPackageExtensionError
from ..utils.github_host import (
build_ado_ssh_url,
build_ssh_url,
Expand Down Expand Up @@ -975,15 +976,20 @@ def _emit_warning(msg: str) -> None:
plugin_root=manifest.plugin_root,
)

if (
dep_ref is None
and _source_needs_explicit_git_path(source)
and _is_in_marketplace_source(plugin, source)
):
if dep_ref is None and _is_in_marketplace_source(plugin, source):
in_repo_path, path_ref = _extract_in_repo_path_and_ref(
plugin, plugin_root=manifest.plugin_root
)
if in_repo_path:
needs_explicit_path = _source_needs_explicit_git_path(source)
if in_repo_path and not needs_explicit_path:
try:
DependencyReference.parse(canonical)
except InvalidVirtualPackageExtensionError:
# The catalog already identifies the repository and path. A
# dotted bundle directory need not fit shorthand's file-name
# heuristic; the normal downloader still validates its content.
needs_explicit_path = True
if in_repo_path and needs_explicit_path:
# Fall back to the marketplace's registered ref when the plugin
# source itself declares no ref and no version_spec overrides it.
# "main" / "HEAD" are excluded because they represent the default
Expand Down
Loading
Loading