From e263dfa47fc61934609ab8b2bd338b7f8fc5f547 Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Thu, 20 Aug 2026 11:08:17 -0300 Subject: [PATCH 1/2] feat: extend latest release filter options --- structkit/filters.py | 94 ++++++++++--- tests/test_filters.py | 1 + tests/test_filters_more.py | 267 ++++++++++++++++++++++++++++++++++++- 3 files changed, 346 insertions(+), 16 deletions(-) diff --git a/structkit/filters.py b/structkit/filters.py index 1d3f66b..1543889 100644 --- a/structkit/filters.py +++ b/structkit/filters.py @@ -12,31 +12,95 @@ cache = TTLCache(maxsize=100, ttl=600) +def _is_git_object_id(value): + return ( + isinstance(value, str) + and re.fullmatch(r"(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})", value) is not None + ) + + +def _resolve_commit_sha(repo, git_object, peel_tags=True, max_depth=10): + """Resolve a GitHub Git object to its commit object ID.""" + seen = set() + + for _ in range(max_depth): + object_type = getattr(git_object, "type", None) + sha = getattr(git_object, "sha", None) + if not isinstance(object_type, str) or not _is_git_object_id(sha): + raise ValueError("Malformed Git object") + + if object_type == "commit": + return sha + if object_type != "tag" or not peel_tags or sha in seen: + raise ValueError("Git object does not resolve to a commit") + + seen.add(sha) + git_tag = repo.get_git_tag(sha) + git_object = getattr(git_tag, "object", None) + + raise ValueError("Annotated tag nesting limit exceeded") + + +def _resolve_tag_sha(repo, tag_name): + if not isinstance(tag_name, str) or not tag_name: + raise ValueError("Malformed release tag name") + git_ref = repo.get_git_ref(f"tags/{tag_name}") + return _resolve_commit_sha(repo, getattr(git_ref, "object", None)) + + +def _resolve_branch_sha(repo, branch_name): + if not isinstance(branch_name, str) or not branch_name: + raise ValueError("Malformed default branch name") + git_ref = repo.get_git_ref(f"heads/{branch_name}") + return _resolve_commit_sha(repo, getattr(git_ref, "object", None), peel_tags=False) + + @cached(cache) -def get_latest_release(repo_name): - token = os.getenv('GITHUB_TOKEN') +def get_latest_release(repo_name, strip_v=False, return_sha=False): + token = os.getenv("GITHUB_TOKEN") # Use the token if available, otherwise proceed without authentication if token: - g = Github(token) + g = Github(token) else: - g = Github() + g = Github() + repo = None try: - # Get the repository object - repo = g.get_repo(repo_name) - # Get the latest release - latest_release = repo.get_latest_release() - return latest_release.tag_name + # Get the repository object + repo = g.get_repo(repo_name) + # Get the latest release + latest_release = repo.get_latest_release() except Exception: - # If an error occurs, return the default branch name - try: - default_branch = repo.default_branch - return default_branch - except Exception as e: - print(f"Error getting default branch: {e}") + # If an error occurs, return the default branch name + try: + if repo is None: + raise ValueError("Repository lookup failed") + default_branch = repo.default_branch + if return_sha: + return _resolve_branch_sha(repo, default_branch) + return default_branch + except Exception as e: + print(f"Error getting default branch: {e}") + return "LATEST_RELEASE_ERROR" + + try: + tag_name = getattr(latest_release, "tag_name", None) + except Exception: + return "LATEST_RELEASE_ERROR" + if not isinstance(tag_name, str) or not tag_name: return "LATEST_RELEASE_ERROR" + if return_sha: + try: + return _resolve_tag_sha(repo, tag_name) + except Exception: + return "LATEST_RELEASE_ERROR" + + if strip_v and tag_name.startswith("v"): + return tag_name[1:] + return tag_name + @cached(cache) def get_default_branch(repo_name): diff --git a/tests/test_filters.py b/tests/test_filters.py index c5fb3d3..ada4564 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -4,6 +4,7 @@ @patch('structkit.filters.Github') @patch('structkit.filters.os.getenv') def test_get_latest_release(mock_getenv, mock_github): + get_latest_release.cache_clear() # Mock the environment variable mock_getenv.return_value = 'fake_token' diff --git a/tests/test_filters_more.py b/tests/test_filters_more.py index cc406e7..282e565 100644 --- a/tests/test_filters_more.py +++ b/tests/test_filters_more.py @@ -1,9 +1,33 @@ -import os import types import pytest from structkit import filters +SHA_1 = "1" * 40 +SHA_2 = "2" * 40 +SHA_3 = "3" * 40 + + +@pytest.fixture(autouse=True) +def clear_filter_cache(): + filters.cache.clear() + + +def install_fake_repo(monkeypatch, repo): + class FakeGithub: + def __init__(self, token=None): + self.token = token + + def get_repo(self, name): + assert name == "owner/repo" + return repo + + monkeypatch.setattr(filters, "Github", FakeGithub) + + +def git_object(object_type, sha): + return types.SimpleNamespace(type=object_type, sha=sha) + def test_slugify_basic(): assert filters.slugify("Hello World!") == "hello-world" @@ -18,6 +42,7 @@ class FakeRepo: class FakeGithub: def __init__(self, token=None): self.token = token + def get_repo(self, name): assert name == "owner/repo" return FakeRepo() @@ -43,14 +68,17 @@ def get_repo(self, name): def test_get_latest_release_success(monkeypatch): class FakeRepo: default_branch = "dev" + def get_latest_release(self): class R: tag_name = "v1.2.3" + return R() class FakeGithub: def __init__(self, token=None): pass + def get_repo(self, name): return FakeRepo() @@ -62,13 +90,110 @@ def get_repo(self, name): assert filters.get_latest_release("owner/repo") == "v1.2.3" +@pytest.mark.parametrize( + ("tag_name", "expected"), + [ + ("v1.2.3", "1.2.3"), + ("vv1.2.3", "v1.2.3"), + ("1.2.3", "1.2.3"), + ("V1.2.3", "V1.2.3"), + ], +) +def test_get_latest_release_strip_v(monkeypatch, tag_name, expected): + repo = types.SimpleNamespace( + get_latest_release=lambda: types.SimpleNamespace(tag_name=tag_name) + ) + install_fake_repo(monkeypatch, repo) + + assert filters.get_latest_release("owner/repo", strip_v=True) == expected + + +def test_get_latest_release_strip_v_disabled(monkeypatch): + repo = types.SimpleNamespace( + get_latest_release=lambda: types.SimpleNamespace(tag_name="v1.2.3") + ) + install_fake_repo(monkeypatch, repo) + + assert filters.get_latest_release("owner/repo", strip_v=False) == "v1.2.3" + + +def test_get_latest_release_lightweight_tag_sha(monkeypatch): + class FakeRepo: + def get_latest_release(self): + return types.SimpleNamespace(tag_name="v1.2.3") + + def get_git_ref(self, ref): + assert ref == "tags/v1.2.3" + return types.SimpleNamespace(object=git_object("commit", SHA_1)) + + install_fake_repo(monkeypatch, FakeRepo()) + + assert filters.get_latest_release("owner/repo", return_sha=True) == SHA_1 + + +def test_get_latest_release_annotated_tag_sha(monkeypatch): + class FakeRepo: + def get_latest_release(self): + return types.SimpleNamespace(tag_name="v1.2.3") + + def get_git_ref(self, ref): + assert ref == "tags/v1.2.3" + return types.SimpleNamespace(object=git_object("tag", SHA_1)) + + def get_git_tag(self, sha): + assert sha == SHA_1 + return types.SimpleNamespace(object=git_object("commit", SHA_2)) + + install_fake_repo(monkeypatch, FakeRepo()) + + assert filters.get_latest_release("owner/repo", return_sha=True) == SHA_2 + + +def test_get_latest_release_nested_annotated_tag_sha(monkeypatch): + class FakeRepo: + def get_latest_release(self): + return types.SimpleNamespace(tag_name="v1.2.3") + + def get_git_ref(self, ref): + return types.SimpleNamespace(object=git_object("tag", SHA_1)) + + def get_git_tag(self, sha): + objects = { + SHA_1: git_object("tag", SHA_2), + SHA_2: git_object("commit", SHA_3), + } + return types.SimpleNamespace(object=objects[sha]) + + install_fake_repo(monkeypatch, FakeRepo()) + + assert filters.get_latest_release("owner/repo", return_sha=True) == SHA_3 + + +def test_return_sha_takes_precedence_over_strip_v(monkeypatch): + repo = types.SimpleNamespace( + get_latest_release=lambda: types.SimpleNamespace(tag_name="v1.2.3"), + get_git_ref=lambda ref: types.SimpleNamespace( + object=git_object("commit", SHA_1) + ), + ) + install_fake_repo(monkeypatch, repo) + + assert ( + filters.get_latest_release("owner/repo", strip_v=True, return_sha=True) == SHA_1 + ) + + def test_get_latest_release_falls_back_to_default_branch(monkeypatch): class FakeRepo: default_branch = "main" + def get_latest_release(self): raise Exception("no releases") class FakeGithub: + def __init__(self, token=None): + pass + def get_repo(self, name): return FakeRepo() @@ -78,12 +203,152 @@ def get_repo(self, name): assert filters.get_latest_release("owner/repo") == "main" +def test_strip_v_does_not_change_default_branch_fallback(monkeypatch): + repo = types.SimpleNamespace( + default_branch="v-main", + get_latest_release=lambda: (_ for _ in ()).throw(Exception("no releases")), + ) + install_fake_repo(monkeypatch, repo) + + assert filters.get_latest_release("owner/repo", strip_v=True) == "v-main" + + +def test_release_failure_falls_back_to_default_branch_sha(monkeypatch): + class FakeRepo: + default_branch = "main" + + def get_latest_release(self): + raise Exception("no releases") + + def get_git_ref(self, ref): + assert ref == "heads/main" + return types.SimpleNamespace(object=git_object("commit", SHA_1)) + + install_fake_repo(monkeypatch, FakeRepo()) + + assert filters.get_latest_release("owner/repo", return_sha=True) == SHA_1 + + +@pytest.mark.parametrize( + "git_ref", + [ + None, + types.SimpleNamespace(object=None), + types.SimpleNamespace(object=git_object(None, SHA_1)), + types.SimpleNamespace(object=git_object("commit", None)), + types.SimpleNamespace(object=git_object("commit", "not-a-sha")), + types.SimpleNamespace(object=git_object("tree", SHA_1)), + ], +) +def test_malformed_release_git_data_returns_error(monkeypatch, git_ref): + class FakeRepo: + def get_latest_release(self): + return types.SimpleNamespace(tag_name="v1.2.3") + + def get_git_ref(self, ref): + return git_ref + + install_fake_repo(monkeypatch, FakeRepo()) + + assert ( + filters.get_latest_release("owner/repo", return_sha=True) + == "LATEST_RELEASE_ERROR" + ) + + +@pytest.mark.parametrize("tag_name", [None, "", 123]) +def test_malformed_release_tag_name_returns_error(monkeypatch, tag_name): + repo = types.SimpleNamespace( + get_latest_release=lambda: types.SimpleNamespace(tag_name=tag_name) + ) + install_fake_repo(monkeypatch, repo) + + assert filters.get_latest_release("owner/repo") == "LATEST_RELEASE_ERROR" + + +def test_release_tag_name_lookup_failure_returns_error(monkeypatch): + class FakeRelease: + @property + def tag_name(self): + raise Exception("malformed release") + + repo = types.SimpleNamespace(get_latest_release=lambda: FakeRelease()) + install_fake_repo(monkeypatch, repo) + + assert filters.get_latest_release("owner/repo") == "LATEST_RELEASE_ERROR" + + +def test_annotated_tag_cycle_returns_error(monkeypatch): + class FakeRepo: + def get_latest_release(self): + return types.SimpleNamespace(tag_name="v1.2.3") + + def get_git_ref(self, ref): + return types.SimpleNamespace(object=git_object("tag", SHA_1)) + + def get_git_tag(self, sha): + return types.SimpleNamespace(object=git_object("tag", SHA_1)) + + install_fake_repo(monkeypatch, FakeRepo()) + + assert ( + filters.get_latest_release("owner/repo", return_sha=True) + == "LATEST_RELEASE_ERROR" + ) + + +def test_selected_release_sha_lookup_failure_does_not_fallback(monkeypatch): + class FakeRepo: + default_branch = "main" + + def get_latest_release(self): + return types.SimpleNamespace(tag_name="v1.2.3") + + def get_git_ref(self, ref): + raise Exception("lookup failed") + + install_fake_repo(monkeypatch, FakeRepo()) + + assert ( + filters.get_latest_release("owner/repo", return_sha=True) + == "LATEST_RELEASE_ERROR" + ) + + +@pytest.mark.parametrize( + "branch_ref", + [ + types.SimpleNamespace(object=git_object("tag", SHA_1)), + types.SimpleNamespace(object=git_object("commit", "bad")), + ], +) +def test_malformed_default_branch_sha_returns_error(monkeypatch, branch_ref): + class FakeRepo: + default_branch = "main" + + def get_latest_release(self): + raise Exception("no releases") + + def get_git_ref(self, ref): + return branch_ref + + install_fake_repo(monkeypatch, FakeRepo()) + + assert ( + filters.get_latest_release("owner/repo", return_sha=True) + == "LATEST_RELEASE_ERROR" + ) + + def test_get_latest_release_error(monkeypatch): class FakeRepo: def get_latest_release(self): raise Exception("no releases") class FakeGithub: + def __init__(self, token=None): + pass + def get_repo(self, name): raise Exception("bad repo") From bc335b98211fe91e6caf5eb7d5d3bf8ae2892b4d Mon Sep 17 00:00:00 2001 From: Kenneth Belitzky Date: Thu, 20 Aug 2026 11:37:25 -0300 Subject: [PATCH 2/2] docs: document latest release filter options --- docs/configuration.md | 36 +++++++++++++++++++++++++++++------- docs/template-variables.md | 24 ++++++++++++++++++++---- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8e18d6a..b3ec990 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -101,21 +101,43 @@ as you can see, the `author_name` variable is defined on the `variables` section ##### `latest_release` -This filter fetches the latest release version of a GitHub repository. It takes the repository name as an argument. +This filter selects the latest GitHub release for a repository. Its signature is: + +```python +latest_release(repo_name: str, strip_v: bool = False, return_sha: bool = False) -> str +``` + +| Parameter | Type | Default | Behavior | +| --- | --- | --- | --- | +| `repo_name` | `str` | required | GitHub repository in `owner/name` form. | +| `strip_v` | `bool` | `false` | In version mode, remove exactly one leading lowercase `v` from the release tag. Tags without that prefix, including an uppercase `V`, are unchanged. | +| `return_sha` | `bool` | `false` | Return the commit SHA identified by the release tag instead of the tag name. | + +Both options are optional, so existing calls keep their original behavior. For a latest release tagged `v3.2.1`, these examples produce: ```yaml files: - - README.md: + - release.txt: content: | - # MyProject - Latest release: {{@ "httpdss/struct" | latest_release @}} + # v3.2.1 (unchanged default output) + {{@ "httpdss/structkit" | latest_release @}} + + # 3.2.1 (strip one leading lowercase "v") + {{@ "httpdss/structkit" | latest_release(strip_v=true) @}} + + # 0123456789abcdef0123456789abcdef01234567 (release commit) + {{@ "httpdss/structkit" | latest_release(return_sha=true) @}} ``` -This uses PyGithub to fetch the latest release of the repository so setting the `GITHUB_TOKEN` environment variable will give you access to private repositories. +`return_sha=true` takes precedence when both options are enabled. In that case the filter returns the same commit SHA as `return_sha=true` alone; `strip_v` has no effect on a SHA. + +The SHA is resolved from the Git tag reference. Lightweight tags resolve directly, while annotated tags are peeled until a commit is reached. Resolution examines at most 10 Git objects, so no more than nine annotated-tag hops may precede the final commit. The returned object ID must be a full 40-character SHA-1 or 64-character SHA-256 hexadecimal value. If a release was selected but its tag is missing, malformed, too deeply nested, cyclic, or does not resolve to a commit, the filter returns `LATEST_RELEASE_ERROR` rather than a tag-object SHA or an unrelated branch SHA. + +If repository lookup succeeds but latest-release lookup fails, the established fallback remains the repository's default branch name. In SHA mode, the fallback is instead that branch's head commit SHA. `strip_v` is not applied to a fallback branch name. If repository lookup or fallback resolution fails, the result is `LATEST_RELEASE_ERROR`. -If there is an error in the process, the filter will return `LATEST_RELEASE_ERROR`. +This filter uses PyGithub. Set the `GITHUB_TOKEN` environment variable to access private repositories and to receive authenticated API rate limits. -NOTE: you can use this filter to get the latest release for a terraform provider. For example, to get the latest release of the `aws` provider, you can use `{{@ "hashicorp/terraform-provider-aws" | latest_release @}}` or datadog provider `{{@ "DataDog/terraform-provider-datadog" | latest_release @}}`. +You can also use it with Terraform provider repositories, for example `{{@ "hashicorp/terraform-provider-aws" | latest_release(strip_v=true) @}}` or `{{@ "DataDog/terraform-provider-datadog" | latest_release(return_sha=true) @}}`. ##### `slugify` diff --git a/docs/template-variables.md b/docs/template-variables.md index c8b775f..df5b204 100644 --- a/docs/template-variables.md +++ b/docs/template-variables.md @@ -244,16 +244,32 @@ files: ### `latest_release` -Fetch the latest release version from GitHub: +Select the latest GitHub release with the following filter signature: + +```python +latest_release(repo_name: str, strip_v: bool = False, return_sha: bool = False) -> str +``` + +- `strip_v` removes exactly one leading lowercase `v` from a release tag. It defaults to `false`; uppercase `V` and tags without `v` are unchanged. +- `return_sha` returns the commit SHA identified by the release tag instead of its name. It defaults to `false`. +- When both are `true`, `return_sha` takes precedence and `strip_v` has no effect. + +For a release tagged `v22.0.0`: ```yaml files: - - Dockerfile: + - release-info.txt: content: | - FROM node:{{@ "nodejs/node" | latest_release @}} + Version: {{@ "nodejs/node" | latest_release @}} # v22.0.0 + Version without v: {{@ "nodejs/node" | latest_release(strip_v=true) @}} # 22.0.0 + Commit: {{@ "nodejs/node" | latest_release(return_sha=true) @}} # full commit SHA ``` -**Requirements**: Set `GITHUB_TOKEN` environment variable for private repos. +Calls that omit the new options remain unchanged. If repository lookup succeeds but latest-release lookup fails, version mode returns the default branch name and SHA mode returns that branch's head commit SHA; `strip_v` does not alter a fallback branch name. Repository lookup, fallback-resolution, or selected-release SHA-resolution failures return `LATEST_RELEASE_ERROR`. + +SHA mode supports lightweight and annotated tags. Resolution examines at most 10 Git objects, so no more than nine annotated-tag hops may precede the final commit. A tag must ultimately resolve to a commit with a full 40-character SHA-1 or 64-character SHA-256 hexadecimal object ID. A malformed, cyclic, over-nested, missing, or non-commit tag target returns `LATEST_RELEASE_ERROR`; it does not fall back to a different revision after a release has already been selected. + +**Requirements**: Set the `GITHUB_TOKEN` environment variable for private repositories and authenticated API rate limits. ### `slugify`