Back to Blog

    Pin Smarter: Version and SHA Resolution with StructKit's latest_release Filter

    August 20, 2026Kenny Belitzky

    Templates exist to remove decisions you have already made. Yet almost every scaffold eventually hits the same wall: it needs "the current version of X." A base image tag, an action version, a provider constraint, a runtime pin. Hardcode it and every repository generated from that template is born slightly stale — sometimes months stale.

    StructKit's latest_release filter already solved half of this. Give it a GitHub repository and it resolves the newest release tag at render time, so the scaffold is current the moment it is generated. The other half was shape: the filter only ever handed back the raw tag, and a raw tag is frequently not what the destination file will accept.

    Upstream PR #188 (commit 6642fbf) closes that gap with two small optional flags.

    What's new

    The filter signature is now:

    latest_release(repo_name: str, strip_v: bool = False, return_sha: bool = False) -> str
    • strip_v (default false) — removes exactly one leading lowercase v from the tag. v1.2.3 becomes 1.2.3; vv1.2.3 becomes v1.2.3; V1.2.3 is left as V1.2.3. It is deliberately conservative so it never mangles unusual tag schemes.
    • return_sha (default false) — returns the commit SHA that the release tag points at, instead of the tag name.

    Both parameters are appended and optional, so every existing single-argument call renders byte-for-byte as it did before. There is no migration step.

    When both are true, return_sha wins and strip_v becomes a no-op — there is no v to strip off a commit SHA.

    Using it

    The three modes side by side:

    files:
      - release.txt:
          content: |
            # 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) @}}

    Why strip_v matters

    GitHub tags love a leading v. Many of the places you want to paste that version do not.

    Docker image tags:

    files:
      - Dockerfile:
          content: |
            FROM node:{{@ "nodejs/node" | latest_release(strip_v=true) @}}-alpine
            WORKDIR /app

    Terraform provider constraints:

    files:
      - versions.tf:
          content: |
            terraform {
              required_providers {
                aws = {
                  source  = "hashicorp/aws"
                  version = "~> {{@ "hashicorp/terraform-provider-aws" | latest_release(strip_v=true) @}}"
                }
              }
            }

    The same applies to package.json and pyproject.toml dependency pins, and to Helm chart appVersion fields — all of which expect a bare semantic version.

    Before this flag, the usual workaround was | replace("v", ""). That is a trap: it strips every v, so v2024.11-preview quietly becomes 2024.11-preie. strip_v removes one leading lowercase v and nothing else.

    The main event: SHA-pinned GitHub Actions workflows

    Here is the standard advice, which almost nobody follows.

    uses: actions/checkout@v4 is a mutable reference. A Git tag is just a pointer, and pointers can be moved. If a third-party action's tag is re-pointed — by a maintainer mistake or by an attacker with push access — the code running inside your pipeline changes without a single line changing in your repository. And that code runs with your workflow's secrets in scope. GitHub's own security hardening guidance and OpenSSF Scorecard's Pinned-Dependencies check both recommend pinning third-party actions to a full-length commit SHA.

    The reason teams skip it is pure friction. SHAs are unreadable, and resolving one per action by hand — then re-resolving them every time something is released — is miserable work. Removing exactly that friction is what return_sha is for.

    The template

    This is the pattern worth stealing:

    files:
      - .github/workflows/ci.yml:
          content: |
            name: CI
            on: [push, pull_request]
            jobs:
              build:
                runs-on: ubuntu-latest
                steps:
                  - uses: actions/checkout@{{@ "actions/checkout" | latest_release(return_sha=true) @}} # {{@ "actions/checkout" | latest_release @}}
                  - uses: actions/setup-node@{{@ "actions/setup-node" | latest_release(return_sha=true) @}} # {{@ "actions/setup-node" | latest_release @}}
                    with:
                      node-version: {{@ "nodejs/node" | latest_release(strip_v=true) @}}
                  - run: npm ci && npm test

    Each action is referenced twice: once in SHA mode for the uses: value, once in default mode for a trailing comment. The rendered workflow looks like this:

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@1e60f620b9541d16bece96c5465dc8ee9832be0b # v4.0.3
        with:
          node-version: 22.11.0
      - run: npm ci && npm test

    You get the immutable reference where security depends on it, and the human-readable version right next to it where a reviewer's eyes land. This is the same shape Dependabot writes — and the same shape it reads, so Dependabot can keep bumping both the SHA and the comment in repositories that already exist.

    The refresh loop

    The important consequence is where the pin lives. It is not hand-typed into fifty repositories; it is a template expression. "Update all our actions" stops being an audit and becomes a command:

    structkit generate ./structures/ci.yaml .
    git diff

    Put that on a schedule and it maintains itself. A weekly workflow that regenerates from the shared template, commits the diff, and opens a PR keeps pins current across every repository scaffolded from it — with a review step, because a pin change should be seen by a human.

    This composes with Dependabot rather than replacing it. Dependabot updates repositories that already exist. StructKit makes sure new repositories start correct, and that the shared template every future repository inherits stays correct.

    Practical caveats

    latest_release calls the GitHub API. Set GITHUB_TOKEN in the environment to get authenticated rate limits, and to reach private repositories at all:

    export GITHUB_TOKEN=ghp_your_token_here
    structkit generate ./structures/ci.yaml ./my-service

    Results are cached in-process for ten minutes, keyed on the repository and the flags. That second part matters for the pattern above: latest_release and latest_release(return_sha=true) against the same repository are separate cache entries, so each costs its own lookup — and SHA mode makes further calls to resolve the tag ref on top of that. Worth knowing before you scaffold twenty actions in one run against an unauthenticated rate limit.

    Failure modes, precisely

    Version resolution is easy to reason about. SHA resolution touches Git internals, so it is worth knowing exactly what it does.

    Tag peeling. SHA mode reads the Git tag ref. Lightweight tags resolve directly to a commit. Annotated tags are peeled until a commit is reached. At most 10 Git objects are examined — so up to nine annotated-tag hops — and the resulting object ID must be a full 40-character SHA-1 or 64-character SHA-256 hex value.

    Fallbacks. If the repository is found but the latest-release lookup fails, version mode falls back to the default branch name, and SHA mode falls back to that branch's head commit SHA. strip_v is never applied to a fallback branch name — a branch called v2-main keeps its v.

    The loud failure. If repository lookup or fallback resolution fails — or if a selected release's tag is missing, malformed, cyclic, nested too deeply, or does not resolve to a commit — the filter returns the literal string LATEST_RELEASE_ERROR.

    That is a design choice, not an oversight. Once a release has been selected, StructKit will not silently substitute an unrelated revision. LATEST_RELEASE_ERROR sitting in a generated file is noisy and fails fast in review or CI. A workflow quietly pinned to a commit nobody chose is the failure you would never notice.

    Wrap-up

    Two optional booleans is a small change. Together they move version policy into the template: readable versions where humans read them, immutable SHAs where security depends on them, and one command to refresh both everywhere.

    Full reference: latest_release in Template Variables and the filters section of Configuration. The implementation lives in httpdss/structkit.