semstat reports facts about semantic version strings. It parses them,
classifies them, and orders them. It never produces a new one: there is no bump,
no tag, no write of any kind.
Support. This tool encodes vCluster release conventions, in particular the prerelease vocabulary in
semstat type. It is public because our CI needs to download it without a token, not because it is a product. There is no support promise and no stability guarantee outside loft-sh CI.
Release pipelines ask "is this version newer" in shell, and the usual answer is
sort -V, which is close enough to be dangerous. It ranks build metadata, which
the semver spec says must never affect precedence, and it sorts v2.0.0-rc.2
above v2.0.0, so a release candidate can block the release it was a candidate
for.
A GitHub Action cannot fix it, because those comparisons live inside bash loops and shell functions where a workflow step cannot reach. A binary can.
Comparing is only half of it. Routing a tag needs to know that -rc.2 is a
release candidate and -next.internal.3 is not, and that a tag shaped like
neither must stop the pipeline rather than be guessed at.
brew install loft-sh/tap/semstat # macOS
go install github.com/loft-sh/semstat@latestOr download a binary for your platform from the releases page, or run the image:
docker run --rm ghcr.io/loft-sh/semstat:latest type v2.0.0-rc.2semstat <command> [arguments]
Every version is an argument; nothing reads stdin. A leading v is optional and
never changes an answer. Ordering follows semver precedence, so build metadata
never affects it and a prerelease always sorts below its final release.
| Command | Prints | Exit codes |
|---|---|---|
validate <version> |
nothing, reason on stderr when invalid | 0 valid, 1 invalid |
parse <version> |
one line of JSON | 0, 2 unreadable |
type <version> |
one word | 0, 2 unreadable or unroutable |
compare <a> <b> |
-1, 0 or 1 |
0, 2 unreadable |
gt <a> <b> |
nothing | 0 above, 1 not above, 2 unreadable |
version |
semstat's own version | 0 |
help |
the usage text | 0 |
Misuse is always 2: an unknown command, or the wrong number of arguments.
Is this a version at all? The answer is the exit code, so it reads as a plain condition. Nothing goes to stdout; the reason goes to stderr.
$ semstat validate v1.2.3
$ echo $?
0
$ semstat validate 1.2
semstat: "1.2" is not a valid semantic version: invalid semantic version
$ echo $?
1It accepts any valid semver, including the suffixes type refuses, because "is
this parseable" and "is this a release we route" are different questions.
The fields, as one line of JSON. Every key is always present. prerelease and
build are null when absent rather than empty strings, so a caller can tell
"no prerelease" from "a prerelease I failed to read".
$ semstat parse v1.2.3-rc.1+build.5
{"major":1,"minor":2,"patch":3,"prerelease":"rc.1","build":"build.5","raw":"v1.2.3-rc.1+build.5"}
$ semstat parse v1.2.3
{"major":1,"minor":2,"patch":3,"prerelease":null,"build":null,"raw":"v1.2.3"}raw is the string you passed in, v and all, so a caller can echo the tag it
was given back to a user without rebuilding it.
Which channel does this release belong to? One word: stable, alpha, beta,
rc, next or next-internal.
$ semstat type v2.0.0
stable
$ semstat type v2.0.0-rc.2
rc
$ semstat type v1.2.3-next.internal.4
next-internalA suffix outside that vocabulary is an error, not a sixth answer. This is the
one command that rejects versions validate accepts, and the counter is part of
the shape, so -rc and -rc2 fail where -rc.2 succeeds.
$ semstat type v1.2.3-preview.1
semstat: version "v1.2.3-preview.1" has an unsupported prerelease suffix "preview.1"; supported are -alpha.N, -beta.N, -rc.N, -next.N or -next.internal.N, or no suffix at all for a stable release
$ echo $?
2How does a sort against b? -1 below, 0 equal, 1 above.
$ semstat compare v2.1.0 v2.0.9
1
$ semstat compare v2.0.0-rc.2 v2.0.0
-1
$ semstat compare v1.0.0+build.1 v1.0.0+build.2
0Two valid versions always have an answer, so all three of those exit 0. Exit 2 means one of them could not be read, never that they compared unfavourably.
Is a strictly above b? A condition, printing nothing.
$ semstat gt v2.0.0 v1.9.9 ; echo $? # 0, yes
$ semstat gt v1.9.9 v2.0.0 ; echo $? # 1, no
$ semstat gt v2.0.0 v2.0.0 ; echo $? # 1, equal is not above
$ semstat gt v2.0.0 nope ; echo $? # 2, unreadableStrictly above, so equal versions answer no. For "newer or the same", use
compare and accept 0 alongside 1.
$ semstat version
1.2.3The version is stamped at release time, so a binary built from source reports
dev. help, -h and --help print the usage to stdout. Running semstat with no
command at all prints the same text to stderr and exits 2, so a bare invocation
in a pipeline fails instead of feeding usage text downstream.
Three codes, not two:
- 0 the command succeeded, or the answer is yes
- 1 the answer is no
- 2 the input could not be understood, or the command was misused
Only validate and gt have an answer that means no. Every other command
reports an unreadable version as 2. The split matters, because the alternative
is the bug this tool was built to remove: without a distinct code, a typo in
$candidate is indistinguishable from a legitimate "not newer".
Reading exit 2 takes a little care, because the shell hides it in exactly the
places you would want it. set -e does not apply to a condition, so if and
&& swallow every non-zero status alike, and $(...) discards the status of
the command inside it. So branch on the code rather than on the command:
if semstat gt "$candidate" "$current"; then
echo "newer"
elif [ $? -eq 1 ]; then
echo "not newer"
else
exit 1 # 2: one of them was not a version
fiand for the commands that print, assign first, where the status survives:
release_type="$(semstat type "$tag")" || exit 1
case "$release_type" in
...
esacEach of these is a job our release tooling actually does.
The incumbent comes from the published releases, not from git tag: a tag can
exist without a release, and a release can be a draft nobody has cut yet.
candidate="${GITHUB_REF_NAME:-$(git describe --tags --exact-match)}"
latest="$(gh release list --exclude-drafts --exclude-pre-releases \
--limit 1 --json tagName --jq '.[0].tagName')"
if semstat gt "$candidate" "$latest"; then
echo "$candidate supersedes $latest"
elif [ $? -eq 1 ]; then
echo "$candidate is not newer than $latest"
else
echo "one of $candidate or $latest is not a version" >&2
exit 1
fiOn a repository whose only releases are prereleases, $latest comes back empty
and that third branch is the one that fires, which is the distinction the exit
codes exist for: nothing to compare against is not the same answer as "older".
Two questions, not one. A prerelease never moves a floating tag, and a stable tag still must not move it backwards. The pipeline here enforces the first and not the second, so a job that promotes images should ask both.
release_type="$(semstat type "$TAG")" || exit 1
[ "$release_type" = stable ] || { echo "$release_type: leaving latest alone"; exit 0; }
if semstat gt "$TAG" "$CURRENT_LATEST"; then
docker buildx imagetools create \
-t ghcr.io/loft-sh/semstat:latest \
"ghcr.io/loft-sh/semstat:${TAG#v}"
elif [ $? -eq 1 ]; then
echo "refusing: $TAG is older than $CURRENT_LATEST" >&2
exit 1
else
echo "refusing: cannot compare $TAG with $CURRENT_LATEST" >&2
exit 1
fiThere is no sort subcommand on purpose: comparison was the part that was
wrong, and a loop around gt is the whole of it. Validate first, so gt only
ever sees two real versions and its answer is yes or no rather than three-way.
newest=""
while read -r tag; do
semstat validate "$tag" 2>/dev/null || { echo "skipping $tag" >&2; continue; }
if [ -z "$newest" ] || semstat gt "$tag" "$newest"; then
newest="$tag"
fi
done < <(git tag -l 'v*')
echo "$newest"Read from a process substitution, not git tag -l | while: a pipeline runs the
loop in a subshell, and $newest is empty again by the time the loop ends.
Given v1.9.0, v2.0.0-rc.2, v2.0.0, v1.10.0 and v2.0.0+build.5, this
picks v2.0.0. sort -V | tail -1 picks v2.0.0-rc.2, which is the promotion
this tool was written to unblock.
Assign first. case on an unquoted command substitution would see an empty
string when the tag is unroutable, match nothing, and continue in silence.
release_type="$(semstat type "$TAG")" || exit 1
case "$release_type" in
stable) publish_to stable ;;
rc|beta|alpha) publish_to prerelease ;;
next|next-internal) publish_to internal ;;
esacFor picking a backport target, or naming a floating image tag.
# Piping straight into jq would hide a parse failure, because jq exits 0 on
# empty input and the assignment would succeed as "v".
parsed="$(semstat parse "$TAG")" || exit 1
line="$(jq -r '"v\(.major).\(.minor)"' <<<"$parsed")" # v2.0.3 -> v2.0- name: Classify the tag
id: release
env:
TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
# Assign, then echo. `echo "type=$(semstat type "$TAG")"` keeps the step
# green on an unroutable tag, because echo succeeds and the substitution's
# status is discarded, and every later `if:` then reads an empty string as
# "not stable" and skips silently.
release_type="$(semstat type "$TAG")"
echo "type=$release_type" >> "$GITHUB_OUTPUT"
- name: Publish the stable artifacts
if: steps.release.outputs.type == 'stable'
run: ./hack/publish-stable.shsemstat validate writes nothing on success, so it reads as a plain condition:
semstat validate "$version" || exit 1semstat type is the one thing here no off-the-shelf tool does, and it is
deliberately narrow:
| Suffix | Type |
|---|---|
| none | stable |
-alpha.N |
alpha |
-beta.N |
beta |
-rc.N |
rc |
-next.N |
next |
-next.internal.N |
next-internal |
The counter is part of the shape, not decoration. -rc.2 is an rc; a bare
-rc is an error, and so is the undotted -rc2. -next.internal is an error
too, because it is far more often -next.internal.N with the counter left off
than it is a next counted by the word "internal", and guessing either way
silently is worse than stopping.
The identifier count is exact. -next.N and -next.internal.N are the only
accepted next shapes, so -rc.1.extra and -next.internalX.1 are both
errors: a trailing identifier means the tag was cut to a shape nobody defined.
Anything else is an error, including suffixes that are perfectly legal semver
such as -preview.1 or -vendor.alpha.1. That is on purpose: a release type
nobody knows how to route must stop the pipeline, not get silently sorted into
the closest-looking branch.
semstat validate takes the wider view and accepts any valid semver, because
"is this parseable" and "is this a version we route" are different questions.
Releases are built by GitHub Actions, signed keyless with cosign, and carry SLSA build provenance.
Give the identity exactly, tag included. A regexp ending in @.* would also
accept a signature made by a run on some other ref, which is most of what the
signature is there to rule out.
tag=v1.2.3
identity="https://github.com/loft-sh/semstat/.github/workflows/release.yaml@refs/tags/${tag}"The release assets. checksums.txt is what carries a signature; the archives are
covered through it, which is why the checksum check is part of verifying and not
a separate courtesy.
gh release download "$tag" --repo loft-sh/semstat
cosign verify-blob checksums.txt --bundle checksums.txt.sigstore.json \
--certificate-identity="$identity" \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com
sha256sum --ignore-missing -c checksums.txtThe image, by digest. Signatures are keyed to a digest, and a tag can be moved onto another one, so resolve it first and verify what you resolved.
digest="$(docker buildx imagetools inspect "ghcr.io/loft-sh/semstat:${tag#v}" \
--format '{{ .Manifest.Digest }}')"
# --new-bundle-format=false is required. The image signature sits on a
# sha256-<digest>.sig tag, and while cosign 3 does fall back to that tag when it
# finds no bundle, the provenance attestation on the same digest is one, and
# cosign stops on a bundle it cannot verify rather than trying the tag.
cosign verify "ghcr.io/loft-sh/semstat@${digest}" \
--new-bundle-format=false \
--certificate-identity="$identity" \
--certificate-oidc-issuer=https://token.actions.githubusercontent.comThe build provenance, which says which workflow and which run produced that
digest. Use gh rather than cosign here: the attestation is signed through
GitHub's own Sigstore instance, and cosign judges it against the public
transparency log, where it has no entry.
gh attestation verify "oci://ghcr.io/loft-sh/semstat@${digest}" \
--repo loft-sh/semstat \
--signer-workflow loft-sh/semstat/.github/workflows/release.yaml \
--source-ref "refs/tags/${tag}"make test # go test ./... -race -cover
make lint # gofmt + go vet
make build # ./semstat
make snapshot # full release build, publishing nothingMasterminds/semver is the only version-handling dependency, and the intent is
that it stays the only one.
Push a tag; the pipeline does the rest.
git tag -s v1.2.3 && git push origin v1.2.3Re-running an existing tag is a workflow_dispatch on release.yaml, because
force-pushing a tag does not re-trigger a workflow. Re-runs replace the release
artifacts and notes rather than appending to them.
A prerelease tag moves neither latest nor the floating {major}.{minor} image
tag, and is marked as a prerelease on GitHub. Releases are otherwise assumed to
be cut in ascending order: nothing checks that a tag is newer than the current
latest, so cutting an older patch after a newer minor would move latest
backwards onto it.
The Homebrew cask is pushed to loft-sh/homebrew-tap with HOMEBREW_TAP_TOKEN,
which must be a loft-bot token: pushing to that repo's default branch needs
approval, per the GitHub Actions Developer Guide. If the secret is absent the
cask upload is skipped and the rest of the release still publishes.