Skip to content

Add the git primitives a release pipeline checks against - #39

Merged
MihaiBojin merged 4 commits into
mainfrom
git-primitives
Sep 11, 2026
Merged

Add the git primitives a release pipeline checks against#39
MihaiBojin merged 4 commits into
mainfrom
git-primitives

Conversation

@MihaiBojin

Copy link
Copy Markdown
Contributor

Four assertions and two accessors that a release pipeline runs before it publishes
anything, so the checks live here once rather than as a copy per repository. Three
byte-identical copies of check-tag-version.bash exist across template-python-package,
jazzy-fish and MihaiBojin/worktrees#2 today, and the helper under them has already
drifted: jazzy-fish's get_tags_at_head lost the 'v*' filter, so a chart tag on the same
commit outranks the release tag.

The remote is resolved, not assumed

git::remote answers from checkout.defaultRemote, then the current branch's remote, then
the sole remote, and refuses when several exist and nothing says which, naming the fix:

$ rt git::remote
origin

origin is a convention rather than an answer. On a fork it names the fork, whose tags are
not the project's releases. remote.pushDefault stays unread because it says where commits
go rather than where they come from, and those differ in exactly that case.

git::latest_version asks git::remote instead of reading remote.origin.url. CI
checkouts have one remote named origin, so the answer there is unchanged.

The version is passed in

$ rt git::assert_tag_version "$(uv version --short)"
Tag matches the version: 0.1.0

$ rt git::assert_tag_version 9.9.9
ERROR: tag/version mismatch, refusing to continue.
ERROR:   tag(s) at HEAD: v0.0.16 v0
ERROR:   version given:  9.9.9

Reading pyproject.toml here would mean a TOML parser, and python::project_name was
deleted in #35 for exactly that cost: 9 recursive Homebrew formulae and 126MB for one
function nobody called. Every ecosystem already has a one-liner, so the caller runs its own
and this stays dependency-free.

Accepts 1.2.3 and v1.2.3. A commit can carry several tags, so one match is enough.

Uncertainty is a refusal

$ rt git::assert_tag_free v0.0.16
ERROR: tag 'v0.0.16' already exists on 'origin'.
ERROR: releases do not move tags; pick the next version.

$ rt git::assert_tag_free v9.9.9
Tag 'v9.9.9' is free on 'origin'.

An assertion rather than a predicate, because git::tag_exists X || release fires on every
non-zero status, so no network, no permission and no remote all read as "the tag is free".
Only git's exit 2, its "no such ref", means carry on. That is the bug jazzy-fish's
tag-release.bash carries today, where 2>/dev/null hides the difference.

The retry that used to need --force

git::release reuses a tag that already points at HEAD:

$ git::release v1.0.0          # after a run whose push failed
Tag 'v1.0.0' already points at HEAD; reusing it.

$ git::release v1.0.0          # HEAD has moved since
ERROR: tag 'v1.0.0' already exists and points at 40f8d88, not HEAD (e317459)
ERROR: delete it or pick another version; releases do not move tags

git tag -a fails on an existing tag even when it names the same commit, and the only flag
that cleared it was --force, which is also handed to the push. So the retry was either
blocked or it moved a published tag. --force keeps meaning the remote.

rt on PATH

install.sh links rt alongside releasetools. Until now rt came only from the
Homebrew formula, so uses: releasetools/cli@v0 followed by rt ... failed while the same
command worked on a brew machine.

Not here

The github::, changelog::, net:: and release:: commands are stacked on top of this
branch, along with removing github::get_version and declaring gh in
github::_internal_check_deps. Nothing in this branch calls gh.

#38 brings git::version_tag and git::latest_version into line with the prefix rule the
new commands follow. Doing it here would mix a breaking rename into a branch that only
adds.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Lp5si1kwiFK1w52a6usA33

MihaiBojin and others added 2 commits September 11, 2026 01:53
git::remote resolves the remote from checkout.defaultRemote, the current
branch's remote, or a sole remote, and refuses when several exist and nothing
says which. git::latest_version now asks it instead of reading
remote.origin.url, so a fork stops resolving releases against the fork.

git::tags_at_head lists the release tags on HEAD, highest first.
git::assert_tag_version refuses unless one of them names the version passed in.
The version is an argument rather than something read from a manifest, so the
library needs no TOML, JSON or YAML reader for callers who never release a
Python, Node or Helm project.

git::assert_tag_free refuses unless the remote definitely lacks the tag. It is
an assertion rather than a predicate because '|| release' fires on every
non-zero status, which turns a network failure into "the tag is free".

git::release reuses a tag that already points at HEAD instead of failing. A run
whose push failed used to leave a tag that only --force could clear, and that
same flag is handed to the push, so the retry either aborted or moved a
published tag. A tag pointing at another commit is still refused.

install.sh links rt alongside releasetools, which until now existed only for
Homebrew users.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp5si1kwiFK1w52a6usA33
release.yaml and test-release.yaml trigger on 'v0.*' tags, so a pull request
reached the merge button with no check having read a line of bash. tests.yaml
runs 'make all' and 'make test' on every pull request and every push to main:
shellcheck over src/, scripts/ and the built dist/, then sourcing the
distributable and running base::check_deps.

Building the dist also exercises generate-dist.sh and generate-install.sh,
which nothing outside a release run otherwise does.

shellcheck 0.9.0 ships with the ubuntu-latest runner image, so there is nothing
to install and no third-party action in the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp5si1kwiFK1w52a6usA33
git::release pushes to the remote git::remote resolves. It still said 'origin'
in both pushes, so on a checkout whose remote is named anything else it created
the tag locally and then died, leaving exactly the half-done state the reuse
path exists to clean up after.

Reusing an existing tag refuses when --sign was requested and that tag carries
no signature. Reuse kept whatever the first attempt created, so a retry with
--sign pushed an unsigned tag and said only that it was reusing one.

install.sh leaves a binary name it did not create. 'ln -sf' is unconditional
and RELEASETOOLS_BINARY_DIR defaults to ~/.local/bin, which every installer
writes into; 'rt' in particular is already shipped by RBTools and Request
Tracker. A link this project made points at releasetools.bash, so an upgrade
still replaces its own.

git::remote's comment and the README claimed a fork stops resolving releases
against itself. It does not: a fork's origin and its tracking branch both name
the fork, and both of the first two steps answer with it. checkout.defaultRemote
is the one that can be told otherwise, and the docs now say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp5si1kwiFK1w52a6usA33
The branches filter matches a pull request's base, so one stacked on another
skipped the workflow entirely: #42 opened against gh-release-helpers with no
check reported at all, while #39 and #40 ran.

A stack is the normal shape for anything larger than one change here, so the
filter costs more than it saves. Pushes are still filtered to main, where the
filter does what it says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lp5si1kwiFK1w52a6usA33
@MihaiBojin
MihaiBojin merged commit 019cb47 into main Sep 11, 2026
3 checks passed
@MihaiBojin
MihaiBojin deleted the git-primitives branch September 11, 2026 01:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant