From 11a9d2731117e5de2eded47860b76dbf49552c2a Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Mon, 27 Apr 2026 23:11:52 +0100 Subject: [PATCH 1/4] fix: marketplace build respects GITHUB_HOST for GHE repos (#1008) Thread the existing default_host() / build_https_clone_url() / AuthResolver pattern (used by apm install) through the marketplace build pipeline. Changes: - RefResolver: accept optional host parameter, use build_https_clone_url() instead of hardcoded github.com for git ls-remote URLs - MarketplaceBuilder: resolve tokens against configured host, use REST API for metadata fetch on GHES/GHE Cloud (raw.githubusercontent.com is github.com-only), skip metadata for non-GitHub hosts - Fix AuthResolver import scoping so classify_host() works when auth_resolver is pre-injected - Add GHE Cloud early-exit when no token (avoids pointless 401) Tests: - Update URL assertions to use urlparse (test convention) - Add 4 RefResolver GHE host tests - Add 3 metadata fetch path tests (GHES REST API, non-GitHub skip, GHE Cloud no-token skip) - Add builder host env test Docs: - CHANGELOG: Fixed entry under [Unreleased] - marketplace-authoring guide: GHES section - apm-usage authentication skill: marketplace build example Closes #1008 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 4 + .../docs/guides/marketplace-authoring.md | 11 +++ .../.apm/skills/apm-usage/authentication.md | 1 + src/apm_cli/marketplace/builder.py | 72 ++++++++++++--- src/apm_cli/marketplace/ref_resolver.py | 9 +- tests/unit/commands/test_marketplace_build.py | 20 +++++ tests/unit/marketplace/test_builder.py | 88 +++++++++++++++++++ tests/unit/marketplace/test_ref_resolver.py | 83 ++++++++++++++--- uv.lock | 2 +- 9 files changed, 265 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91c7fea16..a7d95702b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. (#1008) + ## [0.10.0] - 2026-04-27 ### Added diff --git a/docs/src/content/docs/guides/marketplace-authoring.md b/docs/src/content/docs/guides/marketplace-authoring.md index d30aa8ef0..5a65cb30a 100644 --- a/docs/src/content/docs/guides/marketplace-authoring.md +++ b/docs/src/content/docs/guides/marketplace-authoring.md @@ -269,6 +269,17 @@ Run it first when `build` or `publish` fails in an unfamiliar environment. | `No cached refs (offline)` | First-ever `--offline` build. | Run once online to populate the cache, then retry offline. | | `git ls-remote` auth failure | Private source without credentials. | Ensure your git credentials (SSH agent or `gh auth login`) can reach the source repo. | +### GitHub Enterprise Server + +`apm marketplace build` respects the `GITHUB_HOST` environment variable. Set it before building to resolve packages from a GHES instance: + +```bash +export GITHUB_HOST=github.company.com +apm marketplace build +``` + +Token resolution and metadata fetch use the same host, so existing auth configuration (see [Authentication](../../getting-started/authentication/)) works automatically. + ## Discovering upgrades `apm marketplace outdated` compares the currently resolved version of each package (as captured in `marketplace.json`) against the latest tag available in the source repo. diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index 1160a6d0e..055f9e41b 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -78,6 +78,7 @@ If `ADO_APM_PAT` is set but ADO returns 401, APM silently retries with the `az` export GITHUB_HOST=github.company.com export GITHUB_APM_PAT_MYORG=ghp_ghes_token apm install myorg/internal-package # resolves to github.company.com +apm marketplace build # also resolves to github.company.com ``` ## GHE Cloud data residency (*.ghe.com) diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index 44a8f9fcf..a982f2dfe 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -42,6 +42,7 @@ from .semver import SemVer, parse_semver, satisfies_range from .tag_pattern import build_tag_regex, render_tag from ..utils.path_security import ensure_path_within +from ..utils.github_host import default_host from .yml_schema import MarketplaceYml, PackageEntry, load_marketplace_yml logger = logging.getLogger(__name__) @@ -151,6 +152,8 @@ def __init__( self._auth_resolver = auth_resolver # Resolved once per build, used by worker threads (read-only). self._github_token: Optional[str] = None + self._host: str = default_host() or "github.com" + self._host_info: Optional[object] = None # lazily resolved HostInfo # -- lazy loaders ------------------------------------------------------- @@ -164,6 +167,7 @@ def _get_resolver(self) -> RefResolver: self._resolver = RefResolver( timeout_seconds=self._options.timeout_seconds, offline=self._options.offline, + host=self._host, ) return self._resolver @@ -413,16 +417,60 @@ def _fetch_remote_metadata(self, pkg: ResolvedPackage) -> Optional[Dict[str, str When a GitHub token is available (via ``self._github_token``), it is included as an ``Authorization`` header so private repos can be accessed. + + For non-github.com GitHub-family hosts (GHES, GHE Cloud), uses the + GitHub REST API instead of raw.githubusercontent.com (which is only + available for github.com). For non-GitHub hosts, metadata + enrichment is skipped. """ try: path_prefix = f"{pkg.subdir}/" if pkg.subdir else "" - url = ( - f"https://raw.githubusercontent.com/" - f"{pkg.source_repo}/{pkg.sha}/{path_prefix}apm.yml" - ) - req = urllib.request.Request(url) - if self._github_token: - req.add_header("Authorization", f"token {self._github_token}") + file_path = f"{path_prefix}apm.yml" + + # Determine URL strategy based on host kind + host_kind = getattr(self._host_info, "kind", "github") if self._host_info else "github" + + if host_kind not in ("github", "ghe_cloud", "ghes"): + # Non-GitHub hosts -- skip metadata enrichment + logger.debug( + "Skipping metadata fetch for %s (non-GitHub host: %s)", + pkg.name, + self._host, + ) + return None + + if host_kind == "ghe_cloud" and not self._github_token: + logger.debug( + "Skipping metadata fetch for %s (GHE Cloud requires auth)", + pkg.name, + ) + return None + + if self._host == "github.com": + # github.com -- use fast raw.githubusercontent.com CDN + url = ( + f"https://raw.githubusercontent.com/" + f"{pkg.source_repo}/{pkg.sha}/{file_path}" + ) + req = urllib.request.Request(url) + if self._github_token: + req.add_header("Authorization", f"token {self._github_token}") + else: + # GHES / GHE Cloud -- use REST API + api_base = ( + getattr(self._host_info, "api_base", None) + if self._host_info + else None + ) or f"https://{self._host}/api/v3" + url = ( + f"{api_base}/repos/{pkg.source_repo}/contents/{file_path}" + f"?ref={pkg.sha}" + ) + req = urllib.request.Request(url) + req.add_header("Accept", "application/vnd.github.raw") + if self._github_token: + req.add_header("Authorization", f"token {self._github_token}") + with urllib.request.urlopen(req, timeout=5) as resp: # noqa: S310 raw = resp.read().decode("utf-8") data = yaml.safe_load(raw) @@ -460,13 +508,17 @@ def _resolve_github_token(self) -> Optional[str]: auth failures are logged at debug and silently ignored. """ try: + from ..core.auth import AuthResolver # lazy import + resolver = self._auth_resolver if resolver is None: - from ..core.auth import AuthResolver # lazy import - resolver = AuthResolver() self._auth_resolver = resolver - ctx = resolver.resolve("github.com") # type: ignore[union-attr] + # Always classify the host, regardless of token availability, + # so _fetch_remote_metadata() can branch on host kind. + if self._host_info is None: + self._host_info = AuthResolver.classify_host(self._host) + ctx = resolver.resolve(self._host) # type: ignore[union-attr] if ctx.token: logger.debug("Resolved GitHub token for metadata fetch (source=%s)", ctx.source) return ctx.token diff --git a/src/apm_cli/marketplace/ref_resolver.py b/src/apm_cli/marketplace/ref_resolver.py index b22c3972c..64966adfa 100644 --- a/src/apm_cli/marketplace/ref_resolver.py +++ b/src/apm_cli/marketplace/ref_resolver.py @@ -27,6 +27,7 @@ from .errors import GitLsRemoteError, OfflineMissError from ._git_utils import redact_token as _redact_token from .git_stderr import translate_git_stderr +from ..utils.github_host import default_host, build_https_clone_url __all__ = [ "RemoteRef", @@ -144,10 +145,12 @@ def __init__( timeout_seconds: float = 10.0, offline: bool = False, stderr_translator_enabled: bool = True, + host: Optional[str] = None, ) -> None: self._timeout = timeout_seconds self._offline = offline self._stderr_translator = stderr_translator_enabled + self._host: str = host or default_host() or "github.com" self._cache = RefCache() self._lock = threading.Lock() # Per-remote locks to serialise calls to the same remote while @@ -166,7 +169,7 @@ def _remote_lock(self, owner_repo: str) -> threading.Lock: return self._remote_locks[owner_repo] def list_remote_refs(self, owner_repo: str) -> List[RemoteRef]: - """Fetch all tags and heads from ``https://github.com/.git``. + """Fetch all tags and heads from the configured Git host. Results are cached; subsequent calls for the same remote return the cached value until the TTL expires. @@ -198,7 +201,7 @@ def list_remote_refs(self, owner_repo: str) -> List[RemoteRef]: if self._offline: raise OfflineMissError(package="", remote=owner_repo) - url = f"https://github.com/{owner_repo}.git" + url = build_https_clone_url(self._host, owner_repo) + ".git" env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} try: result = subprocess.run( @@ -273,7 +276,7 @@ def resolve_ref_sha(self, owner_repo: str, ref: str = "HEAD") -> str: GitLsRemoteError When the ref does not exist or the subprocess fails. """ - url = f"https://github.com/{owner_repo}.git" + url = build_https_clone_url(self._host, owner_repo) + ".git" env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} try: result = subprocess.run( diff --git a/tests/unit/commands/test_marketplace_build.py b/tests/unit/commands/test_marketplace_build.py index 84536b86e..501fb3fea 100644 --- a/tests/unit/commands/test_marketplace_build.py +++ b/tests/unit/commands/test_marketplace_build.py @@ -427,3 +427,23 @@ def test_no_traceback_without_verbose(self, MockBuilder, runner, yml_cwd): assert result.exit_code == 1 assert "Traceback" not in result.output assert "Build failed" in result.output + + +# --------------------------------------------------------------------------- +# GHE host support +# --------------------------------------------------------------------------- + + +class TestBuildGHEHost: + """build command -- GHE / custom host scenarios.""" + + def test_build_ghe_host_env( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """MarketplaceBuilder respects GITHUB_HOST for token resolution.""" + monkeypatch.setenv("GITHUB_HOST", "corp.ghe.com") + from apm_cli.marketplace.builder import MarketplaceBuilder, BuildOptions + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text("name: test\noutput: marketplace.json\npackages: []\n") + builder = MarketplaceBuilder(yml_path) + assert builder._host == "corp.ghe.com" diff --git a/tests/unit/marketplace/test_builder.py b/tests/unit/marketplace/test_builder.py index 2caa5acc6..10b346a98 100644 --- a/tests/unit/marketplace/test_builder.py +++ b/tests/unit/marketplace/test_builder.py @@ -4,8 +4,10 @@ import json import textwrap +import urllib.parse from collections import OrderedDict from pathlib import Path +from types import SimpleNamespace from typing import Any, Dict, List, Optional from unittest.mock import patch @@ -1785,3 +1787,89 @@ def test_prefetch_metadata_works_without_token(self, tmp_path: Path) -> None: assert req.get_header("Authorization") is None # Result was still populated (public repo) assert "public-pkg" in results + + +# --------------------------------------------------------------------------- +# _fetch_remote_metadata: GHE / custom host branching +# --------------------------------------------------------------------------- + + +class TestFetchRemoteMetadataGHEHost: + """Tests for _fetch_remote_metadata host-routing logic (GHES, GHE Cloud, generic).""" + + def _make_pkg( + self, + *, + name: str = "test-pkg", + source_repo: str = "acme/tools", + subdir: Optional[str] = None, + sha: str = _SHA_A, + ) -> ResolvedPackage: + return ResolvedPackage( + name=name, + source_repo=source_repo, + subdir=subdir, + ref="v1.0.0", + sha=sha, + requested_version="^1.0.0", + tags=(), + is_prerelease=False, + ) + + def _make_builder(self, tmp_path: Path) -> MarketplaceBuilder: + return MarketplaceBuilder(_write_yml(tmp_path, _BASIC_YML)) + + def test_metadata_fetch_ghes_uses_rest_api(self, tmp_path: Path) -> None: + """GHES host triggers REST API URL and sets Accept: application/vnd.github.raw.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + builder._host = "corp.ghe.com" + builder._github_token = "test-token" + builder._host_info = SimpleNamespace( + kind="ghes", + api_base="https://corp.ghe.com/api/v3", + ) + yaml_body = b"description: GHES tool\nversion: 1.2.3\n" + mock_resp = _FakeHTTPResponse(yaml_body) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + return_value=mock_resp, + ) as mock_open: + result = builder._fetch_remote_metadata(pkg) + assert result is not None + assert result["description"] == "GHES tool" + req = mock_open.call_args[0][0] + parsed = urllib.parse.urlparse(req.full_url) + assert parsed.hostname == "corp.ghe.com" + assert parsed.path.startswith("/api/v3/repos/") + assert req.get_header("Accept") == "application/vnd.github.raw" + + def test_metadata_fetch_non_github_skipped(self, tmp_path: Path) -> None: + """Non-GitHub host (kind='generic') returns None without any HTTP request.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + builder._host = "gitlab.example.com" + builder._host_info = SimpleNamespace(kind="generic", api_base=None) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + ) as mock_open: + result = builder._fetch_remote_metadata(pkg) + assert result is None + mock_open.assert_not_called() + + def test_metadata_fetch_ghe_cloud_no_token_skipped(self, tmp_path: Path) -> None: + """GHE Cloud host without a token returns None without any HTTP request.""" + pkg = self._make_pkg() + builder = self._make_builder(tmp_path) + builder._host = "mycompany.ghe.com" + builder._github_token = None + builder._host_info = SimpleNamespace( + kind="ghe_cloud", + api_base="https://mycompany.ghe.com/api/v3", + ) + with patch( + "apm_cli.marketplace.builder.urllib.request.urlopen", + ) as mock_open: + result = builder._fetch_remote_metadata(pkg) + assert result is None + mock_open.assert_not_called() diff --git a/tests/unit/marketplace/test_ref_resolver.py b/tests/unit/marketplace/test_ref_resolver.py index 370bf6a5e..d09b74a4e 100644 --- a/tests/unit/marketplace/test_ref_resolver.py +++ b/tests/unit/marketplace/test_ref_resolver.py @@ -4,6 +4,7 @@ import subprocess import time +import urllib.parse from unittest.mock import MagicMock, patch import pytest @@ -314,13 +315,14 @@ def test_empty_repo(self, mock_run: MagicMock) -> None: @patch("apm_cli.marketplace.ref_resolver.subprocess.run") def test_correct_command_args(self, mock_run: MagicMock) -> None: mock_run.return_value = _make_completed(stdout="") - resolver = RefResolver(timeout_seconds=7.5) + resolver = RefResolver(timeout_seconds=7.5, host="github.com") resolver.list_remote_refs("acme/tools") args, kwargs = mock_run.call_args - assert args[0] == [ - "git", "ls-remote", "--tags", "--heads", - "https://github.com/acme/tools.git", - ] + cmd = args[0] + assert cmd[:4] == ["git", "ls-remote", "--tags", "--heads"] + parsed = urllib.parse.urlparse(cmd[4]) + assert parsed.hostname == "github.com" + assert parsed.path.rstrip("/") == "/acme/tools.git" assert kwargs["timeout"] == 7.5 assert kwargs["capture_output"] is True assert kwargs["text"] is True @@ -357,16 +359,17 @@ def test_resolves_specific_ref(self, mock_run: MagicMock) -> None: mock_run.return_value = _make_completed( stdout=f"{_SHA_B}\trefs/heads/main\n", ) - resolver = RefResolver(timeout_seconds=5.0) + resolver = RefResolver(timeout_seconds=5.0, host="github.com") sha = resolver.resolve_ref_sha("acme/tools", ref="main") assert sha == _SHA_B # Verify command uses the ref directly (no --tags --heads). args, kwargs = mock_run.call_args - assert args[0] == [ - "git", "ls-remote", - "https://github.com/acme/tools.git", - "main", - ] + cmd = args[0] + assert cmd[:2] == ["git", "ls-remote"] + assert cmd[-1] == "main" + parsed = urllib.parse.urlparse(cmd[2]) + assert parsed.hostname == "github.com" + assert parsed.path.rstrip("/") == "/acme/tools.git" resolver.close() @patch("apm_cli.marketplace.ref_resolver.subprocess.run") @@ -500,3 +503,61 @@ def test_env_includes_git_askpass(self, mock_run: MagicMock) -> None: "subprocess.run must pass GIT_ASKPASS=echo in env" ) resolver.close() + + +# --------------------------------------------------------------------------- +# GHE / custom host support +# --------------------------------------------------------------------------- + + +class TestRefResolverGHEHost: + """Tests for RefResolver with custom or environment-driven host.""" + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_custom_host_in_url(self, mock_run: MagicMock) -> None: + """RefResolver with explicit host uses that host in ls-remote URL.""" + resolver = RefResolver(host="corp.ghe.com") + mock_run.return_value = _make_completed(stdout="abc123\trefs/tags/v1.0\n", returncode=0) + resolver.list_remote_refs("acme/tools") + args = mock_run.call_args[0][0] + url = args[-1] # last arg is the URL + parsed = urllib.parse.urlparse(url) + assert parsed.hostname == "corp.ghe.com" + assert parsed.path.rstrip("/") == "/acme/tools.git" + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_default_host_env_var(self, mock_run: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None: + """RefResolver respects GITHUB_HOST env var when no explicit host given.""" + monkeypatch.setenv("GITHUB_HOST", "ghe.example.com") + resolver = RefResolver() + mock_run.return_value = _make_completed(stdout="abc123\trefs/tags/v1.0\n", returncode=0) + resolver.list_remote_refs("acme/tools") + args = mock_run.call_args[0][0] + url = args[-1] + parsed = urllib.parse.urlparse(url) + assert parsed.hostname == "ghe.example.com" + resolver.close() + + @patch("apm_cli.marketplace.ref_resolver.subprocess.run") + def test_resolve_ref_sha_custom_host(self, mock_run: MagicMock) -> None: + """resolve_ref_sha with custom host uses that host in URL.""" + resolver = RefResolver(host="corp.ghe.com") + mock_run.return_value = _make_completed( + stdout="deadbeef" * 5 + "\tHEAD\n", returncode=0 + ) + resolver.resolve_ref_sha("acme/tools", "HEAD") + args = mock_run.call_args[0][0] + url = args[-2] # URL is second-to-last, ref is last + parsed = urllib.parse.urlparse(url) + assert parsed.hostname == "corp.ghe.com" + resolver.close() + + def test_empty_github_host_defaults_to_github_com( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Empty GITHUB_HOST falls back to github.com.""" + monkeypatch.setenv("GITHUB_HOST", "") + resolver = RefResolver() + assert resolver._host == "github.com" + resolver.close() diff --git a/uv.lock b/uv.lock index f50405677..8116b2828 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.9.4" +version = "0.10.0" source = { editable = "." } dependencies = [ { name = "click" }, From 239064d1cf5012c0cc9db1e39af7b47b0c22d88c Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 28 Apr 2026 10:49:00 +0100 Subject: [PATCH 2/4] refactor(marketplace): decouple auth from resolution, reuse DependencyReference for URL sources Phase B of #1008 -- decouples authentication from marketplace generation and reuses existing resolution infrastructure for cross-source compatibility. Changes: - RefResolver: accept optional token for authenticated git ls-remote - Builder: extract lazy _ensure_auth() called from _get_resolver() so both resolve() and build() benefit from authenticated ls-remote - Builder: eagerly init resolver before thread pool (race prevention) - Builder: fix _host_info type annotation (Optional["HostInfo"] with TYPE_CHECKING guard) - resolver.py: _resolve_url_source() now delegates to DependencyReference.parse() -- accepts any valid Git URL (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) instead of github.com only - 13 new tests covering token injection, lazy auth, and cross-source URL resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../docs/guides/marketplace-authoring.md | 2 +- src/apm_cli/marketplace/builder.py | 33 ++++++++-- src/apm_cli/marketplace/ref_resolver.py | 14 ++++- src/apm_cli/marketplace/resolver.py | 36 ++++++----- tests/unit/marketplace/test_builder.py | 62 +++++++++++++++++++ .../marketplace/test_marketplace_resolver.py | 26 +++++++- tests/unit/marketplace/test_ref_resolver.py | 50 +++++++++++++++ 8 files changed, 198 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d95702b..3cbd2b52c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. (#1008) +- `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated (private repos work without separate credential setup) and `type: url` sources accept any valid Git URL, not just github.com. (#1008) ## [0.10.0] - 2026-04-27 diff --git a/docs/src/content/docs/guides/marketplace-authoring.md b/docs/src/content/docs/guides/marketplace-authoring.md index 5a65cb30a..78cd0a7a7 100644 --- a/docs/src/content/docs/guides/marketplace-authoring.md +++ b/docs/src/content/docs/guides/marketplace-authoring.md @@ -278,7 +278,7 @@ export GITHUB_HOST=github.company.com apm marketplace build ``` -Token resolution and metadata fetch use the same host, so existing auth configuration (see [Authentication](../../getting-started/authentication/)) works automatically. +Token resolution and metadata fetch use the same host, so existing auth configuration (see [Authentication](../../getting-started/authentication/)) works automatically. `git ls-remote` calls are authenticated with the resolved token, so private GHES repos work without a separate git credential helper. `type: url` sources accept any valid Git URL (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) -- not just github.com. ## Discovering upgrades diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index a982f2dfe..00f64ebac 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -26,7 +26,10 @@ from concurrent.futures import ThreadPoolExecutor, as_completed from dataclasses import dataclass, field from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple + +if TYPE_CHECKING: + from ..core.auth import HostInfo import yaml @@ -153,7 +156,7 @@ def __init__( # Resolved once per build, used by worker threads (read-only). self._github_token: Optional[str] = None self._host: str = default_host() or "github.com" - self._host_info: Optional[object] = None # lazily resolved HostInfo + self._host_info: Optional["HostInfo"] = None # -- lazy loaders ------------------------------------------------------- @@ -164,13 +167,28 @@ def _load_yml(self) -> MarketplaceYml: def _get_resolver(self) -> RefResolver: if self._resolver is None: + self._ensure_auth() self._resolver = RefResolver( timeout_seconds=self._options.timeout_seconds, offline=self._options.offline, host=self._host, + token=self._github_token, ) return self._resolver + def _ensure_auth(self) -> None: + """Lazily resolve host classification and GitHub token. + + Short-circuits when already resolved or when running in offline mode. + Called by ``_get_resolver()`` so both ``resolve()`` and ``build()`` + benefit from authenticated ``git ls-remote``. + """ + if self._github_token is not None: + return + if self._options.offline: + return + self._github_token = self._resolve_github_token() + # -- output path -------------------------------------------------------- def _output_path(self) -> Path: @@ -369,6 +387,11 @@ def resolve(self) -> ResolveResult: results: Dict[int, ResolvedPackage] = {} errors: List[Tuple[str, str]] = [] + # Eagerly resolve auth + create the shared RefResolver before + # spawning workers -- avoids a race on _ensure_auth() and + # matches the pattern used in _prefetch_metadata(). + self._get_resolver() + with ThreadPoolExecutor( max_workers=min(self._options.concurrency, len(entries)) ) as pool: @@ -428,7 +451,7 @@ def _fetch_remote_metadata(self, pkg: ResolvedPackage) -> Optional[Dict[str, str file_path = f"{path_prefix}apm.yml" # Determine URL strategy based on host kind - host_kind = getattr(self._host_info, "kind", "github") if self._host_info else "github" + host_kind = self._host_info.kind if self._host_info else "github" if host_kind not in ("github", "ghe_cloud", "ghes"): # Non-GitHub hosts -- skip metadata enrichment @@ -458,7 +481,7 @@ def _fetch_remote_metadata(self, pkg: ResolvedPackage) -> Optional[Dict[str, str else: # GHES / GHE Cloud -- use REST API api_base = ( - getattr(self._host_info, "api_base", None) + self._host_info.api_base if self._host_info else None ) or f"https://{self._host}/api/v3" @@ -544,7 +567,7 @@ def _prefetch_metadata( return {} # Resolve token once -- threads read self._github_token (immutable). - self._github_token = self._resolve_github_token() + self._ensure_auth() results: Dict[str, Dict[str, str]] = {} workers = min(self._options.concurrency, len(resolved)) diff --git a/src/apm_cli/marketplace/ref_resolver.py b/src/apm_cli/marketplace/ref_resolver.py index 64966adfa..2d8069960 100644 --- a/src/apm_cli/marketplace/ref_resolver.py +++ b/src/apm_cli/marketplace/ref_resolver.py @@ -137,6 +137,10 @@ class RefResolver: stderr_translator_enabled: When ``True`` (default), stderr from failed ``git`` calls is classified via ``translate_git_stderr``. + token: + Optional GitHub PAT to embed in the ``https://`` URL. When set + the URL uses ``x-access-token`` authentication; when ``None`` + (default) git runs unauthenticated. """ def __init__( @@ -146,11 +150,13 @@ def __init__( offline: bool = False, stderr_translator_enabled: bool = True, host: Optional[str] = None, + token: Optional[str] = None, ) -> None: self._timeout = timeout_seconds self._offline = offline self._stderr_translator = stderr_translator_enabled self._host: str = host or default_host() or "github.com" + self._token: Optional[str] = token self._cache = RefCache() self._lock = threading.Lock() # Per-remote locks to serialise calls to the same remote while @@ -201,7 +207,9 @@ def list_remote_refs(self, owner_repo: str) -> List[RemoteRef]: if self._offline: raise OfflineMissError(package="", remote=owner_repo) - url = build_https_clone_url(self._host, owner_repo) + ".git" + url = build_https_clone_url(self._host, owner_repo, token=self._token) + if not url.endswith(".git"): + url += ".git" env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} try: result = subprocess.run( @@ -276,7 +284,9 @@ def resolve_ref_sha(self, owner_repo: str, ref: str = "HEAD") -> str: GitLsRemoteError When the ref does not exist or the subprocess fails. """ - url = build_https_clone_url(self._host, owner_repo) + ".git" + url = build_https_clone_url(self._host, owner_repo, token=self._token) + if not url.endswith(".git"): + url += ".git" env = {**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_ASKPASS": "echo"} try: result = subprocess.run( diff --git a/src/apm_cli/marketplace/resolver.py b/src/apm_cli/marketplace/resolver.py index d94aa49a1..3198c3adb 100644 --- a/src/apm_cli/marketplace/resolver.py +++ b/src/apm_cli/marketplace/resolver.py @@ -13,6 +13,7 @@ from typing import Callable, Optional, Tuple from ..utils.path_security import PathTraversalError, validate_path_segments +from ..models.dependency.reference import DependencyReference from .client import fetch_or_cache from .errors import MarketplaceFetchError, PluginNotFoundError from .models import MarketplacePlugin @@ -95,25 +96,26 @@ def _resolve_github_source(source: dict) -> str: def _resolve_url_source(source: dict) -> str: """Resolve a ``url`` source type. - APM is Git-native -- URL sources that point to GitHub repos are - resolved to ``owner/repo``. Non-GitHub URLs are rejected. + Delegates to ``DependencyReference.parse()`` so that any valid Git URL + (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) is accepted and normalised + to the canonical ``owner/repo[#ref]`` format used by the marketplace. """ url = source.get("url", "") - # Try to extract owner/repo from common GitHub URL patterns - for prefix in ("https://github.com/", "http://github.com/"): - if url.lower().startswith(prefix): - path = url[len(prefix) :].rstrip("/").split("?")[0] - # Remove .git suffix - if path.endswith(".git"): - path = path[:-4] - parts = path.split("/") - if len(parts) >= 2: - return f"{parts[0]}/{parts[1]}" - - raise ValueError( - f"Cannot resolve URL source '{url}' to a Git coordinate. " - f"APM requires Git-based sources (owner/repo format)." - ) + if not url: + raise ValueError("URL source requires a non-empty 'url' field") + try: + dep = DependencyReference.parse(url) + except ValueError as exc: + raise ValueError( + f"Cannot resolve URL source '{url}': {exc}" + ) from exc + if dep.is_local: + raise ValueError( + f"URL source '{url}' resolves to a local path, not a Git coordinate." + ) + if dep.reference: + return f"{dep.repo_url}#{dep.reference}" + return dep.repo_url def _resolve_git_subdir_source(source: dict) -> str: diff --git a/tests/unit/marketplace/test_builder.py b/tests/unit/marketplace/test_builder.py index 10b346a98..98a0f86df 100644 --- a/tests/unit/marketplace/test_builder.py +++ b/tests/unit/marketplace/test_builder.py @@ -1873,3 +1873,65 @@ def test_metadata_fetch_ghe_cloud_no_token_skipped(self, tmp_path: Path) -> None result = builder._fetch_remote_metadata(pkg) assert result is None mock_open.assert_not_called() + + +# --------------------------------------------------------------------------- +# _ensure_auth lazy resolution +# --------------------------------------------------------------------------- + + +class TestEnsureAuth: + """Tests for the lazy _ensure_auth() method.""" + + def test_ensure_auth_populates_token(self, tmp_path: Path) -> None: + """_ensure_auth() resolves token via the injected auth resolver.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text("name: test\noutput: out.json\npackages: []\n") + builder = MarketplaceBuilder(yml_path) + + mock_ctx = SimpleNamespace(token="ghp_resolved", source="env") + # Pre-set _host_info so classify_host() branch is skipped, + # and inject a fake auth resolver so AuthResolver() ctor is skipped. + builder._host_info = SimpleNamespace(kind="github", api_base="https://api.github.com") + builder._auth_resolver = SimpleNamespace(resolve=lambda host: mock_ctx) + + builder._ensure_auth() + + assert builder._github_token == "ghp_resolved" + assert builder._host_info is not None + + def test_ensure_auth_skips_offline(self, tmp_path: Path) -> None: + """_ensure_auth() short-circuits immediately in offline mode.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text("name: test\noutput: out.json\npackages: []\n") + builder = MarketplaceBuilder(yml_path, options=BuildOptions(offline=True)) + + builder._ensure_auth() + + assert builder._github_token is None + + def test_ensure_auth_idempotent(self, tmp_path: Path) -> None: + """Calling _ensure_auth() when token already set does not re-resolve.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text("name: test\noutput: out.json\npackages: []\n") + builder = MarketplaceBuilder(yml_path) + builder._github_token = "already_set" + + with patch.object(builder, "_resolve_github_token") as mock_resolve: + builder._ensure_auth() + mock_resolve.assert_not_called() + + assert builder._github_token == "already_set" + + def test_get_resolver_has_token(self, tmp_path: Path) -> None: + """_get_resolver() passes the resolved token to RefResolver.""" + yml_path = tmp_path / "marketplace.yml" + yml_path.write_text("name: test\noutput: out.json\npackages: []\n") + builder = MarketplaceBuilder(yml_path) + + mock_ctx = SimpleNamespace(token="ghp_wired", source="env") + builder._host_info = SimpleNamespace(kind="github", api_base="https://api.github.com") + builder._auth_resolver = SimpleNamespace(resolve=lambda host: mock_ctx) + + resolver = builder._get_resolver() + assert resolver._token == "ghp_wired" diff --git a/tests/unit/marketplace/test_marketplace_resolver.py b/tests/unit/marketplace/test_marketplace_resolver.py index edf0edc68..0598d7c62 100644 --- a/tests/unit/marketplace/test_marketplace_resolver.py +++ b/tests/unit/marketplace/test_marketplace_resolver.py @@ -138,8 +138,32 @@ def test_github_https_with_git_suffix(self): assert _resolve_url_source({"url": "https://github.com/owner/repo.git"}) == "owner/repo" def test_non_github_url(self): + # DependencyReference.parse() handles any valid Git host URL + assert _resolve_url_source({"url": "https://gitlab.com/owner/repo"}) == "owner/repo" + + def test_ghes_url(self): + """GHES URLs are resolved via DependencyReference.parse().""" + assert _resolve_url_source({"url": "https://corp.ghe.com/org/repo"}) == "org/repo" + + def test_ssh_url(self): + """SSH URLs are resolved via DependencyReference.parse().""" + assert _resolve_url_source({"url": "git@gitlab.com:org/repo.git"}) == "org/repo" + + def test_url_with_ref_fragment(self): + """URL with #ref preserves the ref in owner/repo#ref format.""" + assert _resolve_url_source({"url": "https://github.com/org/repo#v2.0"}) == "org/repo#v2.0" + + def test_empty_url_rejected(self): + with pytest.raises(ValueError, match="non-empty"): + _resolve_url_source({"url": ""}) + + def test_local_path_rejected(self): + with pytest.raises(ValueError, match="local path"): + _resolve_url_source({"url": "./local/path"}) + + def test_invalid_url_rejected(self): with pytest.raises(ValueError, match="Cannot resolve URL source"): - _resolve_url_source({"url": "https://gitlab.com/owner/repo"}) + _resolve_url_source({"url": ":::invalid:::"}) class TestResolveGitSubdirSource: diff --git a/tests/unit/marketplace/test_ref_resolver.py b/tests/unit/marketplace/test_ref_resolver.py index d09b74a4e..bbb30f6e1 100644 --- a/tests/unit/marketplace/test_ref_resolver.py +++ b/tests/unit/marketplace/test_ref_resolver.py @@ -561,3 +561,53 @@ def test_empty_github_host_defaults_to_github_com( resolver = RefResolver() assert resolver._host == "github.com" resolver.close() + + +# --------------------------------------------------------------------------- +# Token injection into ls-remote URLs +# --------------------------------------------------------------------------- + + +class TestRefResolverTokenInjection: + """Token injection into ls-remote URLs.""" + + def test_token_injected_in_url(self) -> None: + """When token is provided, URL uses x-access-token auth and ends with .git.""" + resolver = RefResolver(host="github.com", token="ghp_testtoken123") + with patch("apm_cli.marketplace.ref_resolver.subprocess.run") as mock_run: + mock_run.return_value = _make_completed("aaaa" * 10 + "\trefs/tags/v1.0.0\n") + resolver.list_remote_refs("owner/repo") + cmd_args = mock_run.call_args[0][0] + # ["git", "ls-remote", "--tags", "--heads", url] + url_arg = cmd_args[cmd_args.index("--heads") + 1] + parsed = urllib.parse.urlparse(url_arg) + assert parsed.scheme == "https" + assert parsed.hostname == "github.com" + assert parsed.username == "x-access-token" + assert parsed.path.endswith(".git") + + def test_no_token_url_has_git_suffix(self) -> None: + """Without token, URL still ends with .git and has no userinfo.""" + resolver = RefResolver(host="github.com") + with patch("apm_cli.marketplace.ref_resolver.subprocess.run") as mock_run: + mock_run.return_value = _make_completed("aaaa" * 10 + "\trefs/tags/v1.0.0\n") + resolver.list_remote_refs("owner/repo") + cmd_args = mock_run.call_args[0][0] + url_arg = cmd_args[cmd_args.index("--heads") + 1] + parsed = urllib.parse.urlparse(url_arg) + assert parsed.path.endswith(".git") + assert parsed.username is None + + def test_resolve_ref_sha_with_token(self) -> None: + """Token is also injected in resolve_ref_sha URL.""" + resolver = RefResolver(host="corp.ghe.com", token="ghp_xxx") + with patch("apm_cli.marketplace.ref_resolver.subprocess.run") as mock_run: + mock_run.return_value = _make_completed("bbbb" * 10 + "\trefs/heads/main\n") + resolver.resolve_ref_sha("org/repo", "main") + cmd_args = mock_run.call_args[0][0] + # ["git", "ls-remote", url, ref] + url_arg = cmd_args[2] + parsed = urllib.parse.urlparse(url_arg) + assert parsed.hostname == "corp.ghe.com" + assert parsed.username == "x-access-token" + assert parsed.path.endswith(".git") From 51a17603a6e7be1112810b64b580d7e832f5b870 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 28 Apr 2026 11:07:51 +0100 Subject: [PATCH 3/4] fix(marketplace): address review panel findings - Add _auth_resolved sentinel to _ensure_auth() for true idempotency - Clarify _resolve_url_source() docstring: host is not preserved (#1010) - Split CHANGELOG #1008 entry into GHE fix + URL-source expansion - Add test documenting host-is-ignored behaviour for non-GitHub URLs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 3 ++- src/apm_cli/marketplace/builder.py | 11 ++++++---- src/apm_cli/marketplace/resolver.py | 8 +++++--- tests/unit/marketplace/test_builder.py | 3 ++- .../marketplace/test_marketplace_resolver.py | 20 +++++++++++++++++++ 5 files changed, 36 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b830d1e4..d2899f2c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Remove redundant `seen` set from `_scan_patterns()` discovery walk (#918) -- `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated (private repos work without separate credential setup) and `type: url` sources accept any valid Git URL, not just github.com. (#1008) +- `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated so private repos work without separate credential setup. (#1008) +- `apm marketplace build` now accepts any valid Git URL (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) for `type: url` sources via `DependencyReference.parse()`, not just github.com. (#1008) ## [0.10.0] - 2026-04-27 diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index 00f64ebac..face9e9d6 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -157,6 +157,7 @@ def __init__( self._github_token: Optional[str] = None self._host: str = default_host() or "github.com" self._host_info: Optional["HostInfo"] = None + self._auth_resolved: bool = False # -- lazy loaders ------------------------------------------------------- @@ -179,15 +180,17 @@ def _get_resolver(self) -> RefResolver: def _ensure_auth(self) -> None: """Lazily resolve host classification and GitHub token. - Short-circuits when already resolved or when running in offline mode. - Called by ``_get_resolver()`` so both ``resolve()`` and ``build()`` - benefit from authenticated ``git ls-remote``. + Short-circuits when already resolved (even if no token was found) + or when running in offline mode. Called by ``_get_resolver()`` so + both ``resolve()`` and ``build()`` benefit from authenticated + ``git ls-remote``. """ - if self._github_token is not None: + if self._auth_resolved: return if self._options.offline: return self._github_token = self._resolve_github_token() + self._auth_resolved = True # -- output path -------------------------------------------------------- diff --git a/src/apm_cli/marketplace/resolver.py b/src/apm_cli/marketplace/resolver.py index 3198c3adb..dbc52eda5 100644 --- a/src/apm_cli/marketplace/resolver.py +++ b/src/apm_cli/marketplace/resolver.py @@ -96,9 +96,11 @@ def _resolve_github_source(source: dict) -> str: def _resolve_url_source(source: dict) -> str: """Resolve a ``url`` source type. - Delegates to ``DependencyReference.parse()`` so that any valid Git URL - (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) is accepted and normalised - to the canonical ``owner/repo[#ref]`` format used by the marketplace. + Delegates to ``DependencyReference.parse()`` to extract the + ``owner/repo`` coordinate from any valid Git URL (GitHub, GHES, GitLab, + Bitbucket, ADO, SSH). The URL's host is *not* preserved -- downstream + resolution (``RefResolver``) uses the configured ``GITHUB_HOST`` for + ``git ls-remote``. True cross-host resolution is tracked in #1010. """ url = source.get("url", "") if not url: diff --git a/tests/unit/marketplace/test_builder.py b/tests/unit/marketplace/test_builder.py index 98a0f86df..b12e2eb5e 100644 --- a/tests/unit/marketplace/test_builder.py +++ b/tests/unit/marketplace/test_builder.py @@ -1911,11 +1911,12 @@ def test_ensure_auth_skips_offline(self, tmp_path: Path) -> None: assert builder._github_token is None def test_ensure_auth_idempotent(self, tmp_path: Path) -> None: - """Calling _ensure_auth() when token already set does not re-resolve.""" + """Calling _ensure_auth() when already resolved does not re-resolve.""" yml_path = tmp_path / "marketplace.yml" yml_path.write_text("name: test\noutput: out.json\npackages: []\n") builder = MarketplaceBuilder(yml_path) builder._github_token = "already_set" + builder._auth_resolved = True with patch.object(builder, "_resolve_github_token") as mock_resolve: builder._ensure_auth() diff --git a/tests/unit/marketplace/test_marketplace_resolver.py b/tests/unit/marketplace/test_marketplace_resolver.py index 0598d7c62..e48f15cc6 100644 --- a/tests/unit/marketplace/test_marketplace_resolver.py +++ b/tests/unit/marketplace/test_marketplace_resolver.py @@ -141,6 +141,26 @@ def test_non_github_url(self): # DependencyReference.parse() handles any valid Git host URL assert _resolve_url_source({"url": "https://gitlab.com/owner/repo"}) == "owner/repo" + def test_url_host_is_not_preserved_in_output(self): + """Host from the URL is stripped -- only owner/repo is returned. + + This is intentional: downstream RefResolver resolves owner/repo + against the configured GITHUB_HOST, not the URL's original host. + Cross-host resolution is tracked in #1010. + """ + # Different hosts all resolve to the same owner/repo coordinate + urls = [ + "https://github.com/acme/tools", + "https://gitlab.com/acme/tools", + "https://bitbucket.org/acme/tools", + "https://corp.ghe.com/acme/tools", + ] + for url in urls: + result = _resolve_url_source({"url": url}) + assert result == "acme/tools", ( + f"Expected 'acme/tools' for {url}, got '{result}'" + ) + def test_ghes_url(self): """GHES URLs are resolved via DependencyReference.parse().""" assert _resolve_url_source({"url": "https://corp.ghe.com/org/repo"}) == "org/repo" From 94fe220305823e695b80a8332d9f4bc6273f6481 Mon Sep 17 00:00:00 2001 From: Sergio Sisternes Date: Tue, 28 Apr 2026 11:23:17 +0100 Subject: [PATCH 4/4] fix(marketplace): address Copilot review findings - Fix _ensure_auth() offline branch to set _auth_resolved sentinel - Clarify CHANGELOG and docs: URL host is not preserved, GITHUB_HOST required - Update marketplace-authoring.md to warn against cross-host URL reliance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/src/content/docs/guides/marketplace-authoring.md | 2 +- src/apm_cli/marketplace/builder.py | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2899f2c2..434e2da5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove redundant `seen` set from `_scan_patterns()` discovery walk (#918) - `apm marketplace build` now respects `GITHUB_HOST` for GitHub Enterprise repos -- ref resolution, token lookup, and metadata fetch all use the configured host instead of hardcoded `github.com`. `git ls-remote` is authenticated so private repos work without separate credential setup. (#1008) -- `apm marketplace build` now accepts any valid Git URL (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) for `type: url` sources via `DependencyReference.parse()`, not just github.com. (#1008) +- `apm marketplace build` now accepts multiple Git URL forms (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) for `type: url` parsing via `DependencyReference.parse()`. Host resolution is still driven by `GITHUB_HOST`, so non-`github.com` hosts require `GITHUB_HOST` to be set accordingly. (#1008) ## [0.10.0] - 2026-04-27 diff --git a/docs/src/content/docs/guides/marketplace-authoring.md b/docs/src/content/docs/guides/marketplace-authoring.md index 78cd0a7a7..ff5a98623 100644 --- a/docs/src/content/docs/guides/marketplace-authoring.md +++ b/docs/src/content/docs/guides/marketplace-authoring.md @@ -278,7 +278,7 @@ export GITHUB_HOST=github.company.com apm marketplace build ``` -Token resolution and metadata fetch use the same host, so existing auth configuration (see [Authentication](../../getting-started/authentication/)) works automatically. `git ls-remote` calls are authenticated with the resolved token, so private GHES repos work without a separate git credential helper. `type: url` sources accept any valid Git URL (GitHub, GHES, GitLab, Bitbucket, ADO, SSH) -- not just github.com. +Token resolution and metadata fetch use the same host, so existing auth configuration (see [Authentication](../../getting-started/authentication/)) works automatically. `git ls-remote` calls are authenticated with the resolved token, so private GHES repos work without a separate git credential helper. `type: url` sources accept Git-style repository URLs as input, including HTTPS and SSH forms, but APM resolves auth and metadata against `GITHUB_HOST`. In practice, the URL host is ignored unless it matches `GITHUB_HOST`, so do not rely on `type: url` for true cross-host resolution. ## Discovering upgrades diff --git a/src/apm_cli/marketplace/builder.py b/src/apm_cli/marketplace/builder.py index face9e9d6..fd88ff03e 100644 --- a/src/apm_cli/marketplace/builder.py +++ b/src/apm_cli/marketplace/builder.py @@ -181,13 +181,15 @@ def _ensure_auth(self) -> None: """Lazily resolve host classification and GitHub token. Short-circuits when already resolved (even if no token was found) - or when running in offline mode. Called by ``_get_resolver()`` so - both ``resolve()`` and ``build()`` benefit from authenticated - ``git ls-remote``. + or when running in offline mode. Offline mode is still marked as + resolved so repeated calls remain idempotent. Called by + ``_get_resolver()`` so both ``resolve()`` and ``build()`` benefit + from authenticated ``git ls-remote`` when available. """ if self._auth_resolved: return if self._options.offline: + self._auth_resolved = True return self._github_token = self._resolve_github_token() self._auth_resolved = True