Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
22 changes: 22 additions & 0 deletions docs/src/content/docs/consumer/manage-dependencies.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,28 @@ SHAs. The lockfile pins the resolved commit either way, so two clones
running `apm install` get the same bytes -- but a branch ref will resolve
to a new SHA on the next `apm update`.

### Pin a semver range

For git-source dependencies you can also pin a semver range as the ref.
APM resolves the range against the remote's tags at install time and
records the concrete tag in the lockfile:

```yaml
dependencies:
apm:
- acme/widget#^1.2.0 # any 1.x >= 1.2.0
- acme/widget#~1.4 # any 1.4.x
- acme/widget#>=2.0 <3 # explicit range
- acme/widget#1.5.x # wildcard
```

APM matches tags against `v{version}` and `{name}--v{version}` patterns
(with `{version}` as a bare-tag fallback) and picks the highest tag that
satisfies the range. The original constraint is preserved in the
lockfile alongside the resolved tag, so `apm install` on a fresh clone
replays the same tag deterministically. Only `apm install --update` or a
manifest change re-resolves to a newer tag.

## Remove a dependency

1. Delete the entry from `apm.yml`.
Expand Down
3 changes: 3 additions & 0 deletions docs/src/content/docs/reference/lockfile-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ Each item in `dependencies` describes one resolved package.
| `marketplace_plugin_name` | string | no | Plugin name as listed in that marketplace. |
| `is_insecure` | bool | no | `true` when the source URL was `http://`. |
| `allow_insecure` | bool | no | `true` when the manifest explicitly opted in to the insecure source. |
| `constraint` | string | git-source semver only | The original semver range from `apm.yml` (`^1.2.0`, `~1.4`). Present when `ref:` was a range; used by drift detection so a manifest range vs. a locked tag (`v1.5.3`) is not a false positive, and by lockfile replay to pin the resolved tag deterministically across installs. |
| `resolved_tag` | string | git-source semver only | The concrete git tag (`v1.5.3`, `widget--v1.5.3`) that satisfied `constraint`. |
| `resolved_at` | string | git-source semver only | RFC 3339 timestamp of the resolution. Surfaces "how stale is this pin?" in `apm why`. |

Fields are emitted only when set. A minimal entry is just `repo_url` plus
`resolved_commit`.
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,8 @@ Remote dependency (git URL plus sub-path):
alias: acme-sec
```

`ref:` accepts either a literal git ref (`main`, `v2.0`, a 40-char commit SHA) or a **semver range** (`^1.2.0`, `~1.4`, `>=2.0 <3`, `1.5.x`). When `ref:` is a semver range, APM resolves it against the remote's tags at install time, matching against `v{version}` and `{name}--v{version}` patterns (with `{version}` as a bare-tag fallback) and selecting the highest tag that satisfies the range. The lockfile records the original `constraint`, the `resolved_tag`, the resolved `version`, the `resolved_commit`, and a `resolved_at` timestamp so subsequent installs replay the same tag deterministically — only `apm install --update` or a manifest change re-resolves. When no remote tag satisfies the range, APM surfaces `NoMatchingTagError` with the inspected patterns.
Comment thread
danielmeppiel marked this conversation as resolved.
Outdated

Local path dependency (development only):

```yaml
Expand Down
343 changes: 343 additions & 0 deletions src/apm_cli/deps/git_semver_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
"""Resolve semver ranges on git-source dependencies against repo tags.

This module is the git-source counterpart of the registry/marketplace
semver resolvers. When an author writes ``acme/foo#^1.2.0`` (string
shorthand) or ``ref: ^1.2.0`` (object form) in ``apm.yml``, the install
pipeline calls :class:`GitSemverResolver` to map the constraint to a
concrete tag on the remote.

Resolution algorithm
--------------------
1. Call :meth:`apm_cli.marketplace.ref_resolver.RefResolver.list_remote_refs`
to enumerate the remote's refs (cached for 5 minutes).
2. Filter tag refs through each tag pattern in order
(``DEFAULT_TAG_PATTERNS``: ``v{version}`` then ``{name}--v{version}``,
followed by a bare ``{version}`` fallback).
3. Discard pre-release versions unless ``include_prerelease=True``.
4. Of all remaining candidates across all patterns, pick the highest
:class:`~apm_cli.marketplace.semver.SemVer` that satisfies the
constraint.

The module purposefully avoids any new transport or cache code -- it
composes ``RefResolver`` (transport + cache), ``tag_pattern.build_tag_regex``
(pattern matching) and ``marketplace.semver`` (version arithmetic).
"""

from __future__ import annotations

from collections.abc import Sequence
from dataclasses import dataclass
from datetime import datetime, timezone

from ..marketplace.ref_resolver import RefResolver, RemoteRef
from ..marketplace.semver import SemVer, parse_semver, satisfies_range
from ..marketplace.tag_pattern import build_tag_regex

__all__ = [
"DEFAULT_TAG_PATTERNS",
"FALLBACK_BARE_PATTERN",
"GitSemverResolution",
"GitSemverResolver",
"NoMatchingTagError",
"iter_semver_tags",
"utc_now_iso",
]

# Default tag-pattern fallback order.
#
# The two-pattern default mirrors the conventions APM already commits to
# elsewhere:
#
# * ``v{version}`` -- universal lockstep convention (most projects).
# * ``{name}--v{version}`` -- Claude Code / PR #1422 per-package convention
# used by multi-marketplace repos. Double dash is intentional and
# matches Claude's published convention; single-dash variants are NOT
# tried by default to avoid silent collisions with branch names like
# ``my-skills-v1`` (a hand-cut release branch).
DEFAULT_TAG_PATTERNS: tuple[str, ...] = ("v{version}", "{name}--v{version}")

# Bare-version fallback (``1.2.3`` literal, no prefix).
#
# Only consulted when neither default pattern produced a match. Without
# this fallback, an author who tags releases as ``1.2.3`` (no leading
# ``v``) and writes ``ref: 1.2.3`` would see ``NoMatchingTagError``
# despite the tag existing -- because ``1.2.3`` parses as a semver
# *range* (exact-version match) and would never round-trip through the
# branch/literal-tag path. Tested by
# ``resolve_bare_version_tag_falls_through_to_third_pattern``.
FALLBACK_BARE_PATTERN: str = "{version}"

_REFS_TAGS_PREFIX = "refs/tags/"


def utc_now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string (no microseconds).

Centralised so :class:`GitSemverResolution` and tests share a single
spelling. Kept out of :mod:`datetime` import sites to make the
timestamp easy to stub in tests.
"""
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()


class NoMatchingTagError(Exception):
"""Raised when no tag on the remote satisfies the requested constraint.

Carries an actionable hint -- the resolver lists the patterns that
were tried and (when available) the highest version it did find so
authors can widen the range or pin a literal tag.
"""

def __init__(self, summary: str, hint: str) -> None:
super().__init__(f"{summary}\n{hint}" if hint else summary)
self.summary = summary
self.hint = hint


@dataclass(frozen=True)
class GitSemverResolution:
"""One git-source semver resolution result.

Attributes
----------
constraint:
The original spec the author wrote (e.g. ``"^1.2.0"``). Preserved
verbatim into the lockfile so ``apm install`` is reproducible
and ``apm update`` knows what to widen.
resolved_version:
Concrete version string the constraint resolved to (e.g. ``"1.5.3"``).
resolved_tag:
Concrete tag name on the remote (e.g. ``"v1.5.3"``).
resolved_sha:
40-char hex SHA the tag points to.
matched_pattern:
The tag pattern that produced the winning tag (one of
``DEFAULT_TAG_PATTERNS`` or ``FALLBACK_BARE_PATTERN``).
resolved_at:
ISO-8601 UTC timestamp captured at resolution time. Surfaced in
the lockfile so audits can answer "when was this tag picked?"
without re-running ``git ls-remote``.
"""

constraint: str
resolved_version: str
resolved_tag: str
resolved_sha: str
matched_pattern: str
resolved_at: str


def iter_semver_tags(
refs: Sequence[RemoteRef],
*,
package_name: str,
patterns: Sequence[str],
) -> list[tuple[SemVer, str, str, str]]:
"""Yield ``(version, tag_name, sha, pattern)`` for tags matching any pattern.

Each tag may match multiple patterns; in that case it appears once
per matching pattern. Callers downstream pick the highest version
across all matches, so duplicates resolve to the same concrete tag
even when patterns overlap.

Non-tag refs (``refs/heads/*``) and peeled-tag refs are skipped.
"""
compiled = [(pat, build_tag_regex(pat)) for pat in patterns]
out: list[tuple[SemVer, str, str, str]] = []
for ref in refs:
if not ref.name.startswith(_REFS_TAGS_PREFIX):
continue
tag_name = ref.name[len(_REFS_TAGS_PREFIX) :]
for pattern, rx in compiled:
# ``build_tag_regex`` substitutes ``{name}`` with a wildcard
# ``[^/]+``. Matching against the rendered tag is enough --
# we don't need to verify the name capture because the
# pattern was author-supplied and tag identity is the
# authority here, not name shape.
m = rx.match(tag_name)
if not m:
continue
try:
version_str = m.group("version")
except (IndexError, KeyError):
continue
v = parse_semver(version_str)
if v is None:
continue
out.append((v, tag_name, ref.sha, pattern))
# Allow same tag to match multiple patterns; the picker
# de-duplicates by (version, sha).
# Silence unused param warning when consumers ignore the package_name.
# (Reserved for a future where patterns include {name} placeholders
# that need expansion before regex compilation.)
_ = package_name
return out
Comment thread
danielmeppiel marked this conversation as resolved.


class GitSemverResolver:
"""Resolve a semver constraint against a remote's git tags.

Composition only: holds a :class:`RefResolver` for transport + cache
and applies the pattern + semver logic on top. Stateless besides
the underlying ref cache.

Parameters
----------
ref_resolver:
The :class:`RefResolver` to use for ``git ls-remote`` calls.
include_prerelease:
When ``True``, prerelease versions are eligible. Defaults to
``False`` (mirrors the marketplace resolver's default).
"""

def __init__(
self,
ref_resolver: RefResolver,
*,
include_prerelease: bool = False,
) -> None:
self._ref_resolver = ref_resolver
self._include_prerelease = include_prerelease

@property
def include_prerelease(self) -> bool:
"""Whether prerelease versions are eligible for resolution."""
return self._include_prerelease

def resolve(
self,
*,
owner_repo: str,
package_name: str,
constraint: str,
tag_patterns: Sequence[str] = DEFAULT_TAG_PATTERNS,
now_iso: str | None = None,
) -> GitSemverResolution:
"""Resolve *constraint* to a concrete tag on ``owner_repo``.

Parameters
----------
owner_repo:
``"owner/repo"`` string (no host, no ``.git`` suffix).
package_name:
Package name used to expand ``{name}`` placeholders in
patterns. Conventionally the trailing path segment of
``owner_repo``.
constraint:
Semver range (e.g. ``"^1.2.0"``, ``"~2.1"``, ``">=1.0 <2.0"``)
or exact version (``"1.2.3"``).
tag_patterns:
Ordered tag patterns to try. Defaults to
:data:`DEFAULT_TAG_PATTERNS`. A bare-version fallback
(:data:`FALLBACK_BARE_PATTERN`) is appended automatically
only when no candidate matches the user-supplied patterns
(see the "Risks" discussion in issue #1488).
now_iso:
Override for the ISO-8601 ``resolved_at`` field; tests use
this to keep assertions deterministic.

Returns
-------
GitSemverResolution
Constraint + winning tag + version + SHA + matched pattern.

Raises
------
NoMatchingTagError
When no tag on the remote satisfies the constraint after
trying the default patterns and the bare-version fallback.
"""
refs = self._ref_resolver.list_remote_refs(owner_repo)
primary_patterns = tuple(tag_patterns)
# Two-pass: try the author-supplied patterns first; only if they
# find zero candidates do we widen to the bare-version fallback.
# That preserves the "literal 1.2.3 tag" edge case without
# silently promoting bare numeric tags to first-class status.
winner = self._pick_best(refs, package_name, primary_patterns, constraint)
if winner is None:
fallback_patterns = (FALLBACK_BARE_PATTERN,)
winner = self._pick_best(refs, package_name, fallback_patterns, constraint)
if winner is None:
raise NoMatchingTagError(
summary=self._format_no_match_summary(
owner_repo=owner_repo,
constraint=constraint,
refs=refs,
),
hint=self._format_no_match_hint(constraint=constraint),
)
version, tag_name, sha, pattern = winner
return GitSemverResolution(
constraint=constraint,
resolved_version=self._render_version(version),
resolved_tag=tag_name,
resolved_sha=sha,
matched_pattern=pattern,
resolved_at=now_iso or utc_now_iso(),
)

# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------

def _pick_best(
self,
refs: Sequence[RemoteRef],
package_name: str,
patterns: Sequence[str],
constraint: str,
) -> tuple[SemVer, str, str, str] | None:
"""Return the highest-version candidate or ``None``."""
candidates = iter_semver_tags(refs, package_name=package_name, patterns=patterns)
if not candidates:
return None
filtered: list[tuple[SemVer, str, str, str]] = []
for cand in candidates:
version = cand[0]
if version.is_prerelease and not self._include_prerelease:
continue
if not satisfies_range(version, constraint):
continue
filtered.append(cand)
if not filtered:
return None
# Sort by SemVer ordering; the last entry is the winner.
filtered.sort(key=lambda t: t[0])
return filtered[-1]

@staticmethod
def _render_version(version: SemVer) -> str:
"""Render a :class:`SemVer` back into its canonical string form."""
base = f"{version.major}.{version.minor}.{version.patch}"
if version.prerelease:
base = f"{base}-{version.prerelease}"
if version.build_meta:
base = f"{base}+{version.build_meta}"
return base

@staticmethod
def _format_no_match_summary(
*,
owner_repo: str,
constraint: str,
refs: Sequence[RemoteRef],
) -> str:
tags = [
r.name[len(_REFS_TAGS_PREFIX) :] for r in refs if r.name.startswith(_REFS_TAGS_PREFIX)
]
if not tags:
return f"No tags on {owner_repo} satisfy {constraint!r}. The remote has no tag refs."
sample = ", ".join(sorted(tags)[:5])
return (
f"No tags on {owner_repo} satisfy {constraint!r}. "
f"Tags considered: {sample}"
f"{' (and more)' if len(tags) > 5 else ''}."
)

@staticmethod
def _format_no_match_hint(*, constraint: str) -> str:
return (
"Hint: widen the range, pin a literal tag with "
f"'ref: <tag>' instead of '{constraint}', or fall back to "
"a branch / SHA ref."
)
Loading
Loading