Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .apm/architecture/owners/transport-auth-platform.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@
"selectors": ["src/apm_cli/deps/revision_pins.py"],
"guards": ["transport-platform-revision-pin-outcome"]
},
{
"id": "ls-remote-tag-commits",
"decision": "Which commit a git ls-remote tag record names: an annotated or signed tag resolves to its peeled ^{} commit, for dependency refs and marketplace source pins alike",
"owner": "deps/git_remote_ops.py (tag_commit_shas); consumers: parse_ls_remote_output and marketplace/ref_resolver.py",
"selectors": ["src/apm_cli/deps/git_remote_ops.py", "src/apm_cli/marketplace/ref_resolver.py"],
"guards": ["transport-platform-ls-remote-tag-commits"]
},
{
"id": "git-semver-preflight-resolution",
"decision": "Git semver preflight eligibility and resolution",
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `apm pack` records the commit an annotated or signed tag points to as `source.sha` instead of the tag object, so plugin installers that check out the tag accept the pin. (by @nefayran, closes #3048, #3161)

## [0.33.0] - 2026-10-02

### Added
Expand Down
63 changes: 63 additions & 0 deletions scripts/architecture_linter/checks/transport_ls_remote_tags.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Ownership check for how ``git ls-remote`` tag records resolve to commits."""

from __future__ import annotations

import re

from scripts.architecture_linter.checks.transport_platform_shared import (
GROUP,
_count_checks,
_forbid_scan,
_src_python,
)
from scripts.architecture_linter.facts import FactsProvider
from scripts.architecture_linter.models import Rule, Violation

_RULE_ID = "transport-platform-ls-remote-tag-commits"
_OWNER = "src/apm_cli/deps/git_remote_ops.py"
_OWNER_DEFINITION = re.compile(r"^def tag_commit_shas\(")
# A peeled ``refs/tags/<name>^{}`` record is recognised only by the owner.
_PEELED_RECORD = re.compile(r"""["']\^\{\}["']""")


def _check_ls_remote_tag_commits(provider: FactsProvider) -> tuple[Violation, ...]:
"""Keep the tag-to-commit decision for ls-remote records in one function."""
inventory = frozenset(provider.inventory)
findings: list[Violation] = []
findings.extend(
_count_checks(
provider,
inventory,
_RULE_ID,
_OWNER,
(("re", _OWNER_DEFINITION.pattern, 1, "eq"),),
"ls-remote tag-to-commit resolution must stay owned by tag_commit_shas",
)
)
findings.extend(
_forbid_scan(
provider,
inventory,
_RULE_ID,
_src_python(provider, exclude={_OWNER}),
_PEELED_RECORD,
"Only deps/git_remote_ops.py may interpret peeled ^{} ls-remote records",
exempt=True,
)
)
return tuple(findings)


RULES: tuple[Rule, ...] = (
Rule(
id=_RULE_ID,
group=GROUP,
guard_ids=(_RULE_ID,),
description="ls-remote tag records resolve to commits only through tag_commit_shas.",
check=_check_ls_remote_tag_commits,
),
)

COLLECTORS: tuple[object, ...] = ()

__all__ = ["COLLECTORS", "RULES"]
8 changes: 8 additions & 0 deletions scripts/architecture_linter/groups/transport_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@
from scripts.architecture_linter.checks.transport_gitlab_sparse import (
RULES as _GITLAB_SPARSE_RULES,
)
from scripts.architecture_linter.checks.transport_ls_remote_tags import (
COLLECTORS as _LS_REMOTE_TAG_COLLECTORS,
)
from scripts.architecture_linter.checks.transport_ls_remote_tags import (
RULES as _LS_REMOTE_TAG_RULES,
)
from scripts.architecture_linter.checks.transport_network_and_runtime import (
COLLECTORS as _NETWORK_COLLECTORS,
)
Expand All @@ -38,13 +44,15 @@
+ _SPARSE_RULES
+ _GITLAB_SPARSE_RULES
+ _REVISION_PIN_RULES
+ _LS_REMOTE_TAG_RULES
+ _NETWORK_RULES
)
COLLECTORS = (
_AUTH_COLLECTORS
+ _CACHE_COLLECTORS
+ _SPARSE_COLLECTORS
+ _REVISION_PIN_COLLECTORS
+ _LS_REMOTE_TAG_COLLECTORS
+ _NETWORK_COLLECTORS
)

Expand Down
97 changes: 60 additions & 37 deletions src/apm_cli/deps/git_remote_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import re
from collections.abc import Iterable

from ..models.apm_package import GitReferenceType, RemoteRef

Expand Down Expand Up @@ -76,27 +77,72 @@ def validate_ls_remote_tag_output(output: str) -> None:
raise RemoteRefParseError("Malformed git ls-remote tag output.")


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

Format per line: ``<sha>\\t<refname>``
def tag_commit_shas(
records: Iterable[tuple[str, str]],
) -> tuple[dict[str, str], frozenset[str]]:
"""Resolve ``git ls-remote`` tag records to the commits they name.

For annotated tags git emits two lines::
``records`` are ``(sha, refname)`` pairs in output order. For an annotated
or signed tag git emits two records::

<tag-object-sha> refs/tags/v1.0.0
<commit-sha> refs/tags/v1.0.0^{}

We want the commit SHA (from the ``^{}`` line) and skip the
tag-object-only line.
A checkout of the tag lands on the commit, so the ``^{}`` record wins and
adds no refname of its own; a lightweight tag keeps its only SHA.

Returns ``(commits, annotated)``: ``commits`` maps each ``refs/tags/<name>``
to its commit SHA in first-seen order, and ``annotated`` holds the
refnames that had a ``^{}`` record. Records outside ``refs/tags/`` are
ignored. This is the one place that interprets ``^{}`` records; the
dependency resolver and the marketplace builder both read tags through it.
"""
commits: dict[str, str] = {}
annotated: set[str] = set()
for sha, refname in records:
if not refname.startswith("refs/tags/"):
continue
if refname.endswith("^{}"):
# Dereferenced commit -- overwrite with the real commit SHA.
#
# SECURITY INVARIANT (load-bearing, do not weaken): only
# ANNOTATED tags emit this peeled ``^{}`` line, so the
# presence of a peeled ref is our sole signal for
# ``annotated=True``. The revision-pin resolver
# (find_latest_annotated_tag) accepts ONLY annotated tags and
# rejects branches and lightweight tags fail-closed, so a
# branch or lightweight tag named like a release can never
# masquerade as a SHA-pin update target. A transport that
# suppressed peeled refs would misclassify a genuine annotated
# tag as lightweight. Revision-pin updates then retain the
# current SHA rather than selecting an unverified target, which
# is the safe direction. Any future edit here that marks a
# non-peeled ref as annotated would break this anti-spoofing
# fence.
refname = refname[:-3]
commits[refname] = sha
annotated.add(refname)
else:
# Only store if we haven't seen the deref line yet.
commits.setdefault(refname, sha)
return commits, frozenset(annotated)


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

Format per line: ``<sha>\\t<refname>``

Tags take the commit SHA :func:`tag_commit_shas` resolves for them, so an
annotated tag carries its peeled commit and ``annotated=True``.

Args:
output: Raw stdout from ``git ls-remote``.

Returns:
Unsorted list of RemoteRef.
"""
tags: dict[str, str] = {} # tag name -> commit sha
annotated_tags: set[str] = set()
tag_records: list[tuple[str, str]] = []
branches: list[RemoteRef] = []

for line in output.splitlines():
Expand All @@ -109,31 +155,7 @@ def parse_ls_remote_output(output: str) -> list[RemoteRef]:
sha, refname = parts[0].strip(), parts[1].strip()

if refname.startswith("refs/tags/"):
tag_name = refname[len("refs/tags/") :]
if tag_name.endswith("^{}"):
# Dereferenced commit -- overwrite with the real commit SHA.
#
# SECURITY INVARIANT (load-bearing, do not weaken): only
# ANNOTATED tags emit this peeled ``^{}`` line, so the
# presence of a peeled ref is our sole signal for
# ``annotated=True``. The revision-pin resolver
# (find_latest_annotated_tag) accepts ONLY annotated tags and
# rejects branches and lightweight tags fail-closed, so a
# branch or lightweight tag named like a release can never
# masquerade as a SHA-pin update target. A transport that
# suppressed peeled refs would misclassify a genuine annotated
# tag as lightweight. Revision-pin updates then retain the
# current SHA rather than selecting an unverified target, which
# is the safe direction. Any future edit here that marks a
# non-peeled ref as annotated would break this anti-spoofing
# fence.
tag_name = tag_name[:-3]
tags[tag_name] = sha
annotated_tags.add(tag_name)
else:
# Only store if we haven't seen the deref line yet.
tags.setdefault(tag_name, sha)

tag_records.append((sha, refname))
elif refname.startswith("refs/heads/"):
branch_name = refname[len("refs/heads/") :]
branches.append(
Expand All @@ -144,14 +166,15 @@ def parse_ls_remote_output(output: str) -> list[RemoteRef]:
)
)

commits, annotated_tags = tag_commit_shas(tag_records)
tag_refs = [
RemoteRef(
name=name,
name=refname[len("refs/tags/") :],
ref_type=GitReferenceType.TAG,
commit_sha=sha,
annotated=name in annotated_tags,
annotated=refname in annotated_tags,
)
for name, sha in tags.items()
for refname, sha in commits.items()
]
return tag_refs + branches

Expand Down
22 changes: 17 additions & 5 deletions src/apm_cli/marketplace/ref_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import urllib.parse
from dataclasses import dataclass

from ..deps.git_remote_ops import tag_commit_shas
from ..utils.git_env import redact_git_diagnostic
from ..utils.github_host import (
build_ado_https_clone_url,
Expand Down Expand Up @@ -170,8 +171,14 @@ def __len__(self) -> int:


def _parse_ls_remote_output(output: str) -> list[RemoteRef]:
"""Parse ``git ls-remote`` stdout into a list of ``RemoteRef``."""
refs: list[RemoteRef] = []
"""Parse ``git ls-remote`` stdout into a list of ``RemoteRef``.

Tags carry the commit a checkout of them lands on, as resolved by
``deps.git_remote_ops.tag_commit_shas``: an annotated or signed tag takes
the SHA of its peeled record, which adds no ref of its own. Branches and
other refs keep their only SHA.
"""
records: list[tuple[str, str]] = []
for line in output.splitlines():
line = line.strip()
if not line:
Expand All @@ -182,9 +189,14 @@ def _parse_ls_remote_output(output: str) -> list[RemoteRef]:
sha, refname = parts[0].strip(), parts[1].strip()
if not _SHA_RE.match(sha):
continue
# Skip peeled tag objects (^{})
if refname.endswith("^{}"):
continue
records.append((sha, refname))
commits, _annotated = tag_commit_shas(records)
refs: list[RemoteRef] = []
for sha, refname in records:
if refname.startswith("refs/tags/"):
if refname not in commits:
continue # the peeled record of an annotated tag
sha = commits[refname]
refs.append(RemoteRef(name=refname, sha=sha))
return refs

Expand Down
8 changes: 8 additions & 0 deletions tests/integration/test_architecture_owner_rule_mutations.py
Original file line number Diff line number Diff line change
Expand Up @@ -1053,6 +1053,14 @@ class MutationCase:
new="def parse_host_qualified_reference_disabled(",
intent="Host-qualified reference parsing loses its canonical coordinate owner.",
),
MutationCase(
guard_id="transport-platform-ls-remote-tag-commits",
rule_id="transport-platform-ls-remote-tag-commits",
path="src/apm_cli/marketplace/ref_resolver.py",
old=" if refname not in commits:",
new=' if refname.endswith("^{}"):',
intent="The marketplace parser interprets peeled ^{} records outside tag_commit_shas.",
),
MutationCase(
guard_id="transport-platform-marketplace-package-remote",
rule_id="transport-platform-marketplace-package-remote",
Expand Down
29 changes: 29 additions & 0 deletions tests/unit/deps/test_git_remote_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
parse_ls_remote_output,
semver_sort_key,
sort_remote_refs,
tag_commit_shas,
validate_ls_remote_tag_output,
)
from apm_cli.models.apm_package import GitReferenceType, RemoteRef
Expand Down Expand Up @@ -125,6 +126,34 @@ def test_whitespace_is_stripped_from_sha_and_refname(self) -> None:
assert refs[0].name == "main"


class TestTagCommitShas:
"""The one place that turns ls-remote tag records into commits (#3048)."""

def test_annotated_tag_takes_its_peeled_commit(self) -> None:
commits, annotated = tag_commit_shas(
[("a" * 40, "refs/tags/v1.0.0"), ("b" * 40, "refs/tags/v1.0.0^{}")]
)
assert commits == {"refs/tags/v1.0.0": "b" * 40}
assert annotated == frozenset({"refs/tags/v1.0.0"})

def test_peeled_record_first_still_wins(self) -> None:
commits, annotated = tag_commit_shas(
[("b" * 40, "refs/tags/v1.0.0^{}"), ("a" * 40, "refs/tags/v1.0.0")]
)
assert commits == {"refs/tags/v1.0.0": "b" * 40}
assert annotated == frozenset({"refs/tags/v1.0.0"})

def test_lightweight_tag_keeps_its_sha_and_is_not_annotated(self) -> None:
commits, annotated = tag_commit_shas([("c" * 40, "refs/tags/v2.0.0")])
assert commits == {"refs/tags/v2.0.0": "c" * 40}
assert annotated == frozenset()

def test_records_outside_refs_tags_are_ignored(self) -> None:
commits, annotated = tag_commit_shas([("d" * 40, "refs/heads/main"), ("e" * 40, "HEAD")])
assert commits == {}
assert annotated == frozenset()


class TestValidateLsRemoteTagOutput:
def test_empty_output_is_a_valid_no_tag_result(self) -> None:
validate_ls_remote_tag_output("")
Expand Down
Loading