Releasing¶
Releases are driven by gh-ship. There is no manual step and no custom script.
The flow¶
push to main
│
▼
🚢 Ship → gh ship prepare
│
▼
🚀 Prepare Release
│ git-cliff computes the version and the changelog
│ Cargo.toml is bumped, CHANGELOG.md written
▼
Release PR (chore(release): X.Y.Z)
│
│ ← review the changelog, then squash-merge
▼
🚢 Ship → gh ship release
│ tags, creates a draft release
▼
📦 Publish Release
│ cross-compiles six targets, uploads assets + SHA256SUMS
│ then publishes to crates.io
▼
the release is made public
│
├─────────────────────┐
▼ ▼
🍺 Homebrew 📦 AUR
│ renders the │ renders both PKGBUILDs and pushes
│ formula and │ them to aur.archlinux.org
│ pushes it to
│ noirbizarre/homebrew-tap
Division of labour¶
gh-ship orchestrates. The Release PR, the tag, the draft, dispatching the publish workflow, making it public.
git-cliff versions. The next version comes from the unreleased Conventional Commits, via
git cliff --bumped-version.
feat bumps the minor (features_always_bump_minor = true).
Before 1.0, a breaking change bumps the minor too (breaking_always_bump_major = false), so 0.1.x → 0.2.0
is how a break is signalled.
This project's on-disk format and ref layout are contracts, and once 1.0 is out that setting flips and a break
bumps the major.
The workflows do the work. Bumping Cargo.toml, writing CHANGELOG.md, compiling.
gh-ship never edits a source file.
Preview¶
Neither writes anything.
Set GITHUB_TOKEN for these
cliff.toml declares [remote.github], so git-cliff calls the GitHub API to work out who contributed for
the first time.
Unauthenticated, that is 60 requests per hour per IP, and it does not fail gracefully — git-cliff panics
trying to parse the rate-limit response as a commit list.
The token only ever reads public metadata.
CI passes the GitHub App's installation token for the same reason — runners share IP addresses, so the
anonymous budget there is permanently spent by other people — and uses the App rather than
secrets.GITHUB_TOKEN because its limit is higher: 5000/hour against 1000/hour per repository.
Configuration¶
| File | Owns |
|---|---|
.github/ship.yml |
The Release PR, the release branch, draft mode |
cliff.toml |
Changelog format, commit grouping, version bumping |
.github/workflows/ship.yaml |
The driver |
.github/workflows/prepare-release.yaml |
Version, changelog, release artifact |
.github/workflows/publish-release.yaml |
Cross-compilation and assets |
.github/workflows/homebrew.yaml |
The Homebrew tap formula |
.github/workflows/aur.yaml |
The two AUR packages |
CI validates the setup on every run:
so a release cannot break at the moment you need it.
Assets¶
Six targets, named git-tpl_<tag>_<platform>.<ext>:
| Target | Asset |
|---|---|
x86_64-unknown-linux-gnu |
linux-amd64.tar.gz |
aarch64-unknown-linux-gnu |
linux-arm64.tar.gz |
x86_64-unknown-linux-musl |
linux-amd64-musl.tar.gz |
x86_64-apple-darwin |
darwin-amd64.tar.gz |
aarch64-apple-darwin |
darwin-arm64.tar.gz |
x86_64-pc-windows-msvc |
windows-amd64.zip |
Plus SHA256SUMS, which covers the archives.
Each archive holds one entry at its root: git-tpl, or git-tpl.exe on Windows.
That plain name is the point of archiving at all — a bare versioned asset forces every packaging format that
consumes a release to rename the file it downloaded, and Git only resolves git tpl through an executable called
exactly git-tpl.
libgit2 is vendored and built from source on every target, so each binary is self-contained and every platform gets identical Git semantics.
crates.io¶
The crates job publishes with Trusted Publishing: the workflow's
OIDC identity is exchanged for a token that lives 30 minutes and is revoked when the job ends.
There is no API key stored in this repository.
It runs last, after the binaries have built on all six targets and been attached to the release.
That ordering is deliberate: publishing to crates.io cannot be undone.
cargo yank hides a version from resolution but never removes it, and the version number is spent permanently.
If the job fails, re-running it costs nothing; the reverse ordering could leave a permanent release on crates.io
with no binaries behind it.
Re-running is safe.
The job checks whether the version is already on crates.io and skips if it is, so a re-run after a partial
failure is a no-op rather than an already exists error — which would otherwise leave the GitHub release stuck
as a draft, because gh-ship only undrafts when this workflow succeeds.
Configuration¶
Set once, under crates.io → git-tpl → Settings → Trusted Publishing:
| Field | Value |
|---|---|
| Repository owner | noirbizarre |
| Repository name | git-tpl |
| Workflow filename | publish-release.yaml |
| Environment | release |
crates.io validates the OIDC claim against every one of these.
Renaming the workflow file, or changing the job's environment:, breaks publishing with a 403 at the worst
possible moment — so if either changes here, change it there too.
The first publish was manual
crates.io has no pending-publisher concept: a trusted publisher can only be configured on a crate that already exists, and the publish endpoint refuses to create new crates. 0.1.0 was therefore published by hand with an API token. Every version since goes through this job.
Homebrew¶
The git-tpl formula in noirbizarre/homebrew-tap is generated,
never hand-edited.
packaging/homebrew/git-tpl.rb is the template; @VERSION@ and the three @SHA256_*@ placeholders are
substituted by .github/workflows/homebrew.yaml, which then commits the result to the tap.
It triggers on release: published, not on the publish workflow finishing.
gh-ship only undrafts the release once 📦 Publish Release has succeeded, so published is the first moment at
which the download URLs baked into the formula actually resolve.
Publishing earlier would put a formula in the tap that 404s.
The checksums are computed from the downloaded assets rather than read out of SHA256SUMS, so a disagreement
between the two cannot reach users — and an unsubstituted placeholder fails the job, because a formula that kept
one would install nothing.
Re-running is safe. The push compares the staged formula against the tap's and exits without committing when they match, so a re-run for a tag already in the tap is a no-op rather than an empty commit.
A tap only covers macOS arm64 and Intel, and Linux x86-64 — the platforms with an asset the formula can trust. homebrew-core is not a target yet: it has an acceptance bar around notability and release history that this project does not clear, and submitting early spends a reviewer's afternoon for nothing.
The AUR¶
Two packages, both generated and never hand-edited.
git-tpl-bin repackages the linux-amd64 and linux-arm64
archives; git-tpl compiles from the tagged source.
They conflict, because both own /usr/bin/git-tpl — a user installs one or the other.
packaging/aur/<pkgname>/PKGBUILD is the template; @VERSION@ and the @SHA256*@ placeholders are substituted
by .github/workflows/aur.yaml, which then commits the result and a regenerated .SRCINFO to the AUR.
The AUR repository is a mirror: nothing is ever read back out of it, so an edit made on aur.archlinux.org is
lost at the next release.
That is the point — one source of truth, and it is this repository.
The trigger and the checksum handling are the same as Homebrew's, for the same reasons: release: published is
the first moment the URLs baked into a PKGBUILD resolve, the checksums are computed from the assets rather than
read out of SHA256SUMS, and a leftover placeholder fails the job.
That last one matters more here than in a formula: an empty sha256sums entry does not fail a makepkg build, it
accepts whatever it downloads.
Re-running is safe, and idempotent for the same reason the tap is: the push compares the staged files against the AUR's and exits without committing when they match.
What the job proves before pushing¶
Both packages are built with makepkg, which is what verifies the checksums, then namcap-ed, installed, and
checked through git tpl --version — not git-tpl --version.
Git resolves git tpl only via an executable named exactly git-tpl on PATH, and a rename inside the archive
is the one failure that would otherwise pass unnoticed.
--nocheck on the build: check() would re-run, in release mode, the suite ♻️ CI already ran on this commit.
The PKGBUILD keeps its check() so users and packagers still run it.
namcap exits 0 whatever it reports
Errors included. A step that just calls namcap logs a broken package and goes green, so the job greps its
output for E: and fails on a match.
W: is tolerated: a Rust binary linking libgcc_s always draws two warnings that cannot both be satisfied,
and failing on those would disable the check entirely.
!lto in the source package is load-bearing
makepkg enables LTO globally, which puts -flto=auto into CFLAGS — and CFLAGS is what the
libgit2-sys build script compiles vendored libgit2 with.
The resulting libgit2.a holds LLVM bitcode rather than objects, and the link fails with a screenful of
undefined symbol: git_repository_open.
Nothing is lost by disabling it: Cargo.toml already sets lto = true on the release profile.
The account¶
An AUR account is a standing maintenance obligation, not a one-off. The AUR creates a pkgbase on its first push, so the workflow imports both packages itself as long as the names are free and the key belongs to the account claiming them. If it fails, the release is unaffected and the packages are stale; re-run it, or do it by hand:
git clone ssh://aur@aur.archlinux.org/git-tpl-bin.git
cd git-tpl-bin
# edit PKGBUILD
makepkg --printsrcinfo > .SRCINFO
git commit -am 'Update to X.Y.Z' && git push
Requirements¶
A GitHub App with APP_CLIENT_ID (a repository variable) and APP_PRIVATE_KEY (a secret) in the release
environment.
The default GITHUB_TOKEN cannot trigger workflows, so a Release PR it authored would show no CI results.
A TAP_TOKEN secret in the homebrew environment: a fine-grained personal access token whose repository access
is limited to noirbizarre/homebrew-tap with Contents: Read and write.
It is scoped to its own environment rather than sharing release, so nothing that publishes a release can also
rewrite the tap.
An AUR_SSH_PRIVATE_KEY secret in the aur environment: the private half of a key registered on the AUR account
that maintains both packages.
Scoped to its own environment for the same reason as TAP_TOKEN.
Re-running either after a failure needs no release: