Skip to content
Draft
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
preserving hand-authored files. Use `--no-dedup` / `--force-instructions`
to write full `AGENTS.md` files anyway. (by @tillig; closes #1730, related:
#1138, #1550) (#1742)
- `apm marketplace check` now resolves each package against its effective host with that host's token, matching `apm pack`. Entries on a non-default host -- a `host.tld/owner/repo` shorthand or a relative source composed onto `marketplace.sourceBase` -- previously failed with `git ls-remote` exit 128 because `check` always probed the default host with no token; local `./` packages now skip the network. Follows up #1736.

## [0.19.0] - 2026-06-09

Expand Down
58 changes: 54 additions & 4 deletions src/apm_cli/commands/marketplace/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
import click

from ...core.command_logger import CommandLogger
from ...marketplace.auth_helpers import resolve_token_for_host
from ...marketplace.errors import GitLsRemoteError, OfflineMissError
from ...marketplace.ref_resolver import RefResolver
from ...marketplace.semver import satisfies_range
from ...marketplace.yml_schema import split_source_base
from . import (
_CheckResult,
_extract_tag_versions,
Expand All @@ -21,6 +23,24 @@
)


def _entry_coordinates(entry, source_base):
"""Return ``(host, owner_repo)`` for *entry*, mirroring the build-time
routing in ``MarketplaceBuilder._remote_source_coordinates`` so that
``check`` and ``pack`` resolve every entry against the same host.

- A per-entry host (``host.tld/owner/repo`` or full URL) is an override.
- Otherwise, when ``marketplace.sourceBase`` is set, a host-less source
composes onto the base.
- Otherwise the source stays a default-host ``owner/repo``.
"""
if entry.host:
return entry.host, entry.source
if source_base:
base_host, base_path = split_source_base(source_base)
return base_host, f"{base_path}/{entry.source}"
return None, entry.source


@marketplace.command(help="Validate marketplace entries are resolvable")
@click.option("--offline", is_flag=True, help="Schema + cached-ref checks only (no network)")
@click.option("--verbose", "-v", is_flag=True, help="Show detailed output")
Expand All @@ -40,15 +60,44 @@ def check(offline, verbose):
symbol="info",
)

resolver = RefResolver(offline=offline)
# One resolver per effective host. An entry whose source named a
# non-default host -- a host-prefixed source or a relative source
# composed onto ``marketplace.sourceBase`` -- must be resolved against
# that host with the host's token, exactly like ``apm pack`` does.
# Default-host entries keep the bare ambient-credential path.
source_base = getattr(yml, "source_base", None)
resolvers: dict[str | None, RefResolver] = {}

def _resolver_for(host: str | None) -> RefResolver:
if host not in resolvers:
if host is None:
resolvers[host] = RefResolver(offline=offline)
else:
token = resolve_token_for_host(host, offline=offline)
resolvers[host] = RefResolver(offline=offline, host=host, token=token)
return resolvers[host]

results = []
failure_count = 0

try:
for entry in yml.packages:
# Local-path packages skip git resolution entirely.
if entry.is_local:
results.append(
_CheckResult(
name=entry.name,
reachable=True,
version_found=True,
ref_ok=True,
error="",
)
)
continue
try:
# Attempt to resolve each entry
refs = resolver.list_remote_refs(entry.source)
# Resolve each entry against its effective host + composed path.
host, owner_repo = _entry_coordinates(entry, source_base)
refs = _resolver_for(host).list_remote_refs(owner_repo)

# Check version/ref resolution
ref_ok = False
Expand Down Expand Up @@ -152,4 +201,5 @@ def check(offline, verbose):
logger.success(f"All {total} entries OK", symbol="check")

finally:
resolver.close()
for resolver in resolvers.values():
resolver.close()
50 changes: 50 additions & 0 deletions src/apm_cli/marketplace/auth_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""Shared auth helper for marketplace commands.

Both ``apm pack`` (``MarketplaceBuilder``) and ``apm marketplace check`` need
to resolve a per-host token before running ``git ls-remote`` against a
non-default host (self-managed GitLab, GHES, ADO, Bitbucket DC). Keeping this
in one place stops the two paths from drifting -- the divergence that left
``check`` resolving every entry against the default host regardless of the
entry's real host (see #1519 follow-up).
"""

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from ..core.auth import AuthResolver

logger = logging.getLogger(__name__)


def resolve_token_for_host(
host: str,
*,
offline: bool = False,
org: str | None = None,
auth_resolver: AuthResolver | None = None,
) -> str | None:
"""Resolve an auth token for *host* via ``AuthResolver``.

Returns ``None`` -- letting ``git`` fall back to ambient credentials --
when offline, when no token is configured for the host, or when
``AuthResolver`` raises. Never raises.

Pass *auth_resolver* to reuse a cached resolver across many calls;
otherwise a fresh one is created per call.
"""
if offline:
return None
try:
from ..core.auth import AuthResolver # lazy import to avoid cycles

resolver = auth_resolver if auth_resolver is not None else AuthResolver()
ctx = resolver.resolve(host) if org is None else resolver.resolve(host, org=org)
if ctx.token:
logger.debug("Resolved token for host %s (source=%s)", host, ctx.source)
return ctx.token
except Exception:
logger.debug("Could not resolve token for host %s", host, exc_info=True)
return None
122 changes: 122 additions & 0 deletions tests/unit/commands/test_marketplace_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,3 +538,125 @@ def test_heads_prefix_not_confused_with_tags(self, MockResolver, runner, tmp_pat
# refs/tags/main stripped to "main" → tag_name == "main" == entry.ref → passes
# (this also tests the tags branch hits and indirectly confirms the heads branch too)
assert result.exit_code == 0


# ---------------------------------------------------------------------------
# Per-host resolution (#1519 follow-up): check must resolve each entry against
# its effective host with that host's token, matching `apm pack`. A relative
# source composed onto `sourceBase`, or a host-prefixed source, targets a
# non-default host; bare `owner/repo` keeps the default-host path.
# ---------------------------------------------------------------------------


class TestCheckPerHostResolution:
@patch("apm_cli.commands.marketplace.check.resolve_token_for_host", return_value="glpat-xyz")
@patch("apm_cli.commands.marketplace.check.RefResolver")
@patch("apm_cli.commands.marketplace.check._load_config_or_exit")
def test_sourcebase_entry_composes_and_uses_host_token(
self, mock_load, MockResolver, mock_token, runner, tmp_path, monkeypatch
):
monkeypatch.chdir(tmp_path)
(tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8")
mock_load.return_value = (
tmp_path,
MarketplaceYml(
name="m",
description="d",
version="1.0.0",
owner=MarketplaceOwner(name="o"),
source_base="https://gitlab.example.com/group/sub/team/project",
packages=(
PackageEntry(
name="my-package",
source="my-package", # relative, host=None as parsed
version="^1.0.0",
),
),
),
)
mock_inst = MockResolver.return_value
mock_inst.list_remote_refs.return_value = _REFS_GOOD
mock_inst.close = MagicMock()

result = runner.invoke(marketplace, ["check"])

assert result.exit_code == 0
# token resolved for the GitLab base host, not github.com
mock_token.assert_called_once_with("gitlab.example.com", offline=False)
# resolver bound to that host with that token
MockResolver.assert_called_once_with(
offline=False, host="gitlab.example.com", token="glpat-xyz"
)
# ls-remote runs against the composed nested path
mock_inst.list_remote_refs.assert_called_once_with("group/sub/team/project/my-package")

@patch("apm_cli.commands.marketplace.check.resolve_token_for_host", return_value="ghp-tok")
@patch("apm_cli.commands.marketplace.check.RefResolver")
@patch("apm_cli.commands.marketplace.check._load_config_or_exit")
def test_host_prefixed_override_uses_that_host(
self, mock_load, MockResolver, mock_token, runner, tmp_path, monkeypatch
):
monkeypatch.chdir(tmp_path)
(tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8")
mock_load.return_value = (
tmp_path,
MarketplaceYml(
name="m",
description="d",
version="1.0.0",
owner=MarketplaceOwner(name="o"),
source_base="https://gitlab.example.com/group/sub",
packages=(
PackageEntry(
name="helper",
source="owner/repo",
version="^1.0.0",
host="github.com", # host-prefixed override: base ignored
),
),
),
)
mock_inst = MockResolver.return_value
mock_inst.list_remote_refs.return_value = _REFS_GOOD
mock_inst.close = MagicMock()

result = runner.invoke(marketplace, ["check"])

assert result.exit_code == 0
mock_token.assert_called_once_with("github.com", offline=False)
MockResolver.assert_called_once_with(offline=False, host="github.com", token="ghp-tok")
mock_inst.list_remote_refs.assert_called_once_with("owner/repo")

@patch("apm_cli.commands.marketplace.check.resolve_token_for_host")
@patch("apm_cli.commands.marketplace.check.RefResolver")
@patch("apm_cli.commands.marketplace.check._load_config_or_exit")
def test_local_entry_makes_zero_ls_remote_calls(
self, mock_load, MockResolver, mock_token, runner, tmp_path, monkeypatch
):
monkeypatch.chdir(tmp_path)
(tmp_path / "marketplace.yml").write_text("---\n", encoding="utf-8")
mock_load.return_value = (
tmp_path,
MarketplaceYml(
name="m",
description="d",
version="1.0.0",
owner=MarketplaceOwner(name="o"),
source_base="https://gitlab.example.com/group/sub",
packages=(
PackageEntry(
name="local-tool",
source="./packages/local-tool",
is_local=True,
),
),
),
)

result = runner.invoke(marketplace, ["check"])

assert result.exit_code == 0
assert "All 1 entries OK" in result.output
# local sources never touch the network or the token resolver
MockResolver.assert_not_called()
mock_token.assert_not_called()
Loading