Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -273,7 +273,7 @@ of the form `<workflow>[@<registry>][#<ref>]`. `@` selects the registry;
shell commands so `#` isn't treated as a comment.

```bash
# Run from default registry (latest tag, or default-branch HEAD if no tags)
# Run from default registry (default-branch HEAD)
conductor run qa-bot

# Run from a specific registry (latest)
Expand Down
21 changes: 11 additions & 10 deletions docs/design/registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ Resolution rules, in order:
3. Missing `@<registry>` → use the configured default registry.
4. An empty registry between `@` and `#` (e.g. `name@#ref`) is allowed and
means "use the default registry at this ref".
5. Missing `#<ref>` → use `latest`. `latest` resolves to the newest
semver-sorted git tag (with a leading `v` stripped for parsing). If the
registry repo has no tags, `latest` falls back to the default branch HEAD.
5. Missing `#<ref>` → use `latest`. `latest` resolves to the **default
branch HEAD** of the registry repo (re-resolved to a fresh commit SHA
on every fetch). To pin to a release, use `#<tag>` explicitly.
6. An empty ref after `#` (e.g. `name@reg#`) is a hard error.
7. Multiple `@` or multiple `#` in a single reference are hard errors.
8. Path-type registries do not support `#<ref>`. Passing
Expand Down Expand Up @@ -154,12 +154,13 @@ auto-discover YAML files in a registry — the maintainer curates the index.
Versioning is automatic and tag-driven for GitHub registries:

- **Auto-discovery**: available versions are the registry repo's git tags,
fetched on demand via the GitHub API. Maintainers do not list versions in
`index.yaml`.
- **`latest` resolution**: `latest` resolves to the newest semver-sorted tag
(a leading `v` is stripped before parsing, so `v1.2.3` and `1.2.3` sort
identically). If the repo has no tags, `latest` falls back to the
default-branch HEAD.
fetched on demand via the GitHub API for display in `registry list`/`show`.
Maintainers do not list versions in `index.yaml`.
- **`latest` resolution**: `latest` (the default when no `#<ref>` is given)
always resolves to the **default branch HEAD** of the registry repo.
This means a bare `name@registry` reference always picks up the newest
commit on the default branch — typical for development workflows.
To pin to a tagged release, use an explicit ref: `name@registry#v1.2.3`.
- **Flexible refs**: any tag, branch, or commit SHA can be pinned via
`#<ref>`. Branch refs are re-resolved to their current commit SHA at
fetch time, so a branch ref always refers to the latest commit on that
Expand Down Expand Up @@ -288,7 +289,7 @@ file of the same name.
| Decision | Choice | Why |
| ----------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| Named registries vs always-inline source | Named, configured once | Mirrors npm/cargo. Short refs in `run` commands. Default registry makes the common case zero-config. |
| Versioning | Auto-discovered from git tags; pin any tag/branch/SHA via `#ref` | Reproducibility without forcing maintainers to maintain a parallel version list. `latest` follows newest semver tag, falling back to default-branch HEAD. Branches and SHAs are first-class refs. |
| Versioning | Default branch HEAD when unpinned; pin any tag/branch/SHA via `#ref` | Bare names always follow the default branch (typical dev workflow). Tagged releases are opt-in via explicit `#<tag>`. Avoids the surprise of `latest` skipping past commits because a tag exists. Branches and SHAs are first-class refs. |
| Local-registry layout | Directory + `index.yaml` | Consistent with GitHub registries. Maintainer controls what's exposed. Local registries do not support refs. |
| Caching strategy | Local cache keyed by resolved commit SHA, atomic writes | Avoids per-run network. SHA-based keys make branch refs self-invalidate on a fresh fetch. SHA-pinned raw URLs bypass the CDN, so no `--force` flag is needed. |
| Reference syntax | `name@registry#ref` | Visually unambiguous: `@` selects the registry, `#` selects a git ref (tag, branch, or SHA). Both segments are independently optional. |
Expand Down
17 changes: 8 additions & 9 deletions src/conductor/registry/version_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
from conductor.registry.errors import RegistryError
from conductor.registry.github import (
get_default_branch,
list_tags,
parse_github_source,
resolve_ref_to_sha,
)
Expand All @@ -21,11 +20,14 @@ def resolve_ref(entry: RegistryEntry, requested: str | None) -> str:
(path registries do not support refs).

For github registries:
* If ``requested`` is None or "latest", returns the newest tag
(semver-sorted when possible). If no tags exist, returns the default
branch name.
* Otherwise returns ``requested`` verbatim — this allows pinning to any
tag, branch, or commit SHA.
* If ``requested`` is None or "latest", returns the default branch
name. The caller will materialize this to the current commit SHA,
so users always pick up the newest commit on the default branch.
* Otherwise returns ``requested`` verbatim — this allows pinning to
any tag, branch, or commit SHA.

Tags are no longer auto-selected as "latest"; users who want a specific
release should pin explicitly with ``#<tag>``.
"""
if entry.type == RegistryType.path:
if requested is not None and requested != "":
Expand All @@ -40,9 +42,6 @@ def resolve_ref(entry: RegistryEntry, requested: str | None) -> str:

if requested is None or requested.lower() == "latest":
owner, repo = parse_github_source(entry.source)
tags = list_tags(owner, repo)
if tags:
return sort_tags(tags)[0]
return get_default_branch(owner, repo)

return requested
Expand Down
32 changes: 11 additions & 21 deletions tests/test_registry/test_version_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,38 +48,28 @@ def test_resolve_ref_path_with_ref_raises() -> None:
# ---------------------------------------------------------------------------


def test_resolve_ref_github_none_picks_newest_tag(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
version_resolver, "list_tags", lambda owner, repo: ["v1.0.0", "v2.0.0", "v1.1.0"]
)
monkeypatch.setattr(
version_resolver,
"get_default_branch",
lambda owner, repo: pytest.fail("should not be called"),
)
assert resolve_ref(_gh_entry(), None) == "v2.0.0"
def test_resolve_ref_github_none_returns_default_branch(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(version_resolver, "get_default_branch", lambda owner, repo: "main")
assert resolve_ref(_gh_entry(), None) == "main"


def test_resolve_ref_github_latest_same_as_none(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
version_resolver, "list_tags", lambda owner, repo: ["v1.0.0", "v2.0.0", "v1.1.0"]
)
assert resolve_ref(_gh_entry(), "latest") == "v2.0.0"
assert resolve_ref(_gh_entry(), "LATEST") == "v2.0.0"
monkeypatch.setattr(version_resolver, "get_default_branch", lambda owner, repo: "trunk")
assert resolve_ref(_gh_entry(), "latest") == "trunk"
assert resolve_ref(_gh_entry(), "LATEST") == "trunk"


def test_resolve_ref_github_no_tags_returns_default_branch(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(version_resolver, "list_tags", lambda owner, repo: [])
def test_resolve_ref_github_does_not_query_tags(monkeypatch: pytest.MonkeyPatch) -> None:
"""Default-branch resolution must not require a tag listing API call."""
monkeypatch.setattr(version_resolver, "get_default_branch", lambda owner, repo: "main")
# If resolve_ref ever imports list_tags again, this test will surface it.
assert resolve_ref(_gh_entry(), None) == "main"


def test_resolve_ref_github_branch_returned_verbatim(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
version_resolver,
"list_tags",
"get_default_branch",
lambda owner, repo: pytest.fail("should not be called"),
)
assert resolve_ref(_gh_entry(), "main") == "main"
Expand All @@ -88,7 +78,7 @@ def test_resolve_ref_github_branch_returned_verbatim(monkeypatch: pytest.MonkeyP
def test_resolve_ref_github_tag_returned_verbatim(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
version_resolver,
"list_tags",
"get_default_branch",
lambda owner, repo: pytest.fail("should not be called"),
)
assert resolve_ref(_gh_entry(), "v1.0.0") == "v1.0.0"
Expand Down
Loading