nixpkgs-plugin-update: default to release tags when appropriate

Switch the updater from always following HEAD to selecting release tags when a
plugin spec is in auto mode and a usable release tag exists.

Explicit branches remain commit-tracked. Auto-selected release tags still fall
back to unstable commit versions when no usable release exists or when the
current git-tracked plugin is newer than the latest tagged release.
This commit is contained in:
Austin Horstman
2026-04-19 10:04:56 -05:00
parent d14de0aa5a
commit ef2ef8eb64
@@ -35,6 +35,7 @@ ATOM_LINK = "{http://www.w3.org/2005/Atom}link" # "
ATOM_UPDATED = "{http://www.w3.org/2005/Atom}updated" # "
GIT_TAGS_PREFIX = "refs/tags/"
AUTO_BRANCH = ""
VERSION_DATE_PATTERN = re.compile(r"(\d{4}-\d{2}-\d{2})$")
VERSION_TAG_PATTERN = re.compile(r"^(.+?)-unstable-")
@@ -97,6 +98,33 @@ def make_request(url: str, token=None) -> urllib.request.Request:
Redirects = dict["PluginDesc", "Repo"]
def select_latest_tag(
tags: list[str],
normalize: Callable[[str], str | None] | None = None,
) -> str | None:
best_version: tuple[str, Any] | None = None
best_text: tuple[str, str] | None = None
for tag in tags:
candidate = tag if normalize is None else normalize(tag)
if candidate is None:
continue
try:
version = parse_version(candidate)
if best_version is None or version > best_version[1]:
best_version = (tag, version)
except InvalidVersion:
if best_text is None or candidate > best_text[1]:
best_text = (tag, candidate)
if best_version is not None:
return best_version[0]
if best_text is not None:
return best_text[0]
return None
class Repo:
def __init__(self, uri: str, branch: str) -> None:
self.uri = uri
@@ -127,11 +155,17 @@ class Repo:
@retry(urllib.error.URLError, tries=4, delay=3, backoff=2)
def latest_commit(self) -> tuple[str, datetime]:
log.debug("Latest commit")
loaded = self._prefetch(None)
loaded = self._prefetch(None, fetch_submodules=False)
updated = datetime.strptime(loaded["date"], "%Y-%m-%dT%H:%M:%S%z")
return loaded["rev"], updated
@retry(urllib.error.URLError, tries=4, delay=3, backoff=2)
def resolve_ref(self, ref: str) -> tuple[str, datetime]:
loaded = self._prefetch(ref, fetch_submodules=False)
updated = datetime.strptime(loaded["date"], "%Y-%m-%dT%H:%M:%S%z")
return loaded["rev"], updated
@retry(urllib.error.URLError, tries=4, delay=3, backoff=2)
def get_latest_tag(self) -> str | None:
try:
@@ -157,21 +191,10 @@ class Repo:
if not tags:
return None
valid_versions = []
invalid_tags = []
for tag in tags:
try:
version = parse_version(tag)
valid_versions.append((tag, version))
except InvalidVersion:
invalid_tags.append(tag)
if valid_versions:
latest_tag = max(valid_versions, key=lambda x: x[1])[0]
elif invalid_tags:
latest_tag = max(invalid_tags)
else:
latest_tag = select_latest_tag(tags, normalize_release_version)
if latest_tag is None:
latest_tag = select_latest_tag(tags)
if latest_tag is None:
log.debug("No tags found for %s", self.uri)
return None
@@ -184,8 +207,11 @@ class Repo:
log.warning("Unexpected error fetching tags for %s: %s", self.uri, e)
return None
def _prefetch(self, ref: str | None):
cmd = ["nix-prefetch-git", "--quiet", "--fetch-submodules", self.uri]
def _prefetch(self, ref: str | None, fetch_submodules: bool = True):
cmd = ["nix-prefetch-git", "--quiet"]
if fetch_submodules:
cmd.append("--fetch-submodules")
cmd.append(self.uri)
if ref is not None:
cmd.append(ref)
log.debug(cmd)
@@ -266,6 +292,20 @@ class RepoGitHub(Repo):
updated = datetime.strptime(updated_tag.text, "%Y-%m-%dT%H:%M:%SZ")
return Path(str(url.path)).name, updated
@retry(urllib.error.URLError, tries=4, delay=3, backoff=2)
def resolve_ref(self, ref: str) -> tuple[str, datetime]:
if ref == self.branch:
return self.latest_commit()
self._check_ref_redirect(ref)
return super().resolve_ref(ref)
@retry(urllib.error.URLError, tries=4, delay=3, backoff=2)
def _check_ref_redirect(self, ref: str) -> None:
ref_url = self.url(f"tree/{urllib.parse.quote(ref, safe='')}")
ref_req = make_request(ref_url, self.token)
with urllib.request.urlopen(ref_req, timeout=10) as req:
self._check_for_redirect(ref_url, req)
def _execute_graphql(self, query: str, variables: dict) -> dict:
graphql_url = "https://api.github.com/graphql"
@@ -412,7 +452,7 @@ class RepoGitHub(Repo):
urlsplit(response_url).path.strip("/").split("/")[:2]
)
new_repo = RepoGitHub(owner=new_owner, repo=new_name, branch=self.branch)
new_repo = RepoGitHub(owner=new_owner, repo=new_name, branch=self._branch)
self.redirect = new_repo
def prefetch(self, commit: str) -> str:
@@ -472,7 +512,7 @@ class PluginDesc:
@staticmethod
def load_from_string(config: FetchConfig, line: str) -> "PluginDesc":
branch = "HEAD"
branch = AUTO_BRANCH
alias = None
uri = line
if " as " in uri:
@@ -562,6 +602,50 @@ def make_unstable_version(date: datetime, last_tag: str | None) -> str:
return f"{tag_part}-unstable-{date_str}"
def get_commit_target(
repo: Repo,
branch: str,
latest_tag: str | None,
) -> tuple[str, datetime, str, str | None]:
if branch == "HEAD":
commit, date = repo.latest_commit()
else:
commit, date = repo.resolve_ref(branch)
return commit, date, make_unstable_version(date, latest_tag), None
def select_plugin_target(
plugin_desc: PluginDesc,
current_plugin: Plugin | None,
latest_tag: str | None,
) -> tuple[str, datetime, str, str | None]:
branch = plugin_desc.branch
if branch != AUTO_BRANCH:
return get_commit_target(plugin_desc.repo, branch, latest_tag)
release_version = (
normalize_release_version(latest_tag) if latest_tag is not None else None
)
if release_version is None:
return get_commit_target(plugin_desc.repo, "HEAD", latest_tag)
release_commit, release_date = plugin_desc.repo.resolve_ref(
f"{GIT_TAGS_PREFIX}{latest_tag}"
)
if current_plugin is not None:
if (
current_plugin.date is not None
and current_plugin.tag is None
and current_plugin.date.date() > release_date.date()
):
return get_commit_target(plugin_desc.repo, "HEAD", latest_tag)
return release_commit, release_date, release_version, latest_tag
def load_plugins_from_csv(
config: FetchConfig,
input_file: Path,
@@ -768,7 +852,14 @@ class Editor:
cache: Cache = Cache(
[plugin for _description, plugin in current_plugins], self.cache_file
)
_prefetch = functools.partial(prefetch, cache=cache)
current_plugin_map = {
plugin.normalized_name: plugin for _description, plugin in current_plugins
}
_prefetch = functools.partial(
prefetch,
cache=cache,
current_plugins=current_plugin_map,
)
to_update_for_filter = [x.replace(".", "-") for x in to_update]
plugins_to_update = (
@@ -812,7 +903,6 @@ class Editor:
# Track version changes for commit message generation
updated_plugins = []
current_plugin_map = {p.normalized_name: p for _, p in current_plugins}
for _, new_plugin in plugins:
old_plugin = current_plugin_map.get(new_plugin.normalized_name)
if old_plugin and old_plugin.version != new_plugin.version:
@@ -1010,9 +1100,9 @@ class CleanEnvironment(object):
def prefetch_plugin(
p: PluginDesc,
cache: "Cache | None" = None,
current_plugin: Plugin | None = None,
) -> tuple[Plugin, Repo | None]:
log.info(f"Fetching source for plugin {p.name} from {p.repo.uri}@{p.branch}")
commit, date = p.repo.latest_commit()
latest_tag = p.repo.get_latest_tag()
if latest_tag:
@@ -1020,8 +1110,11 @@ def prefetch_plugin(
else:
log.debug("No tags found for %s, will use '0' prefix", p.name)
version = make_unstable_version(date, latest_tag)
source_tag = None
commit, date, version, source_tag = select_plugin_target(
p,
current_plugin,
latest_tag,
)
cached_plugin = cache[commit] if cache else None
if cached_plugin is not None:
@@ -1167,9 +1260,13 @@ class Cache:
def prefetch(
pluginDesc: PluginDesc,
cache: Cache,
current_plugins: dict[str, Plugin] | None = None,
) -> tuple[PluginDesc, Exception | Plugin, Repo | None]:
try:
plugin, redirect = prefetch_plugin(pluginDesc, cache)
current_plugin = None
if current_plugins is not None:
current_plugin = current_plugins.get(pluginDesc.name.replace(".", "-"))
plugin, redirect = prefetch_plugin(pluginDesc, cache, current_plugin)
cache[plugin.commit] = plugin
return (pluginDesc, plugin, redirect)
except Exception as e: