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
36 changes: 29 additions & 7 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
24 changes: 20 additions & 4 deletions docs/template-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
94 changes: 79 additions & 15 deletions structkit/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
1 change: 1 addition & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
Loading
Loading