- Go 85.1%
- Shell 14.5%
- Python 0.3%
- Dockerfile 0.1%
Combined bake-reset (release-toolkit#173) + auto-re-pin self-workflows to v0.63.1 (release-toolkit#283). [skip ci] suppresses the push trigger so no CI run fires (release-toolkit#202). Manifest committed + pushed first (release-toolkit#179 ordering: work-loss-safe by position). Tracker: frankenbit/release-toolkit#283 |
||
|---|---|---|
| .forgejo | ||
| .gitea/workflows | ||
| changelog.d | ||
| cmd/rt | ||
| composite/bootstrap-rt | ||
| docs | ||
| examples | ||
| internal | ||
| scripts | ||
| testdata/workflow-schema | ||
| tests | ||
| .gitignore | ||
| .goreleaser.yaml | ||
| .register-allowlist | ||
| .release-toolkit-manifest.json | ||
| action.yml | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| docker-entrypoint.sh | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| minisign.pub | ||
| README.md | ||
| release-toolkit.yml | ||
| VERSION | ||
release-toolkit
A shared, Forgejo-native release pipeline you reference instead of copy-pasting release machinery into every repository. Add one config file and a few workflow stubs, and your project gets a consistent, operator-gated release flow — cut → draft → publish → deploy — with an auto-maintained CHANGELOG and a CI check that catches version-number drift before it ships.
Built for any project on Forgejo Actions that wants a real release pipeline without building (or re-building) one — it already runs its own releases and tmux-tell's (that repository is private, so its releases are not anonymously readable — release-toolkit's own, linked above, are).
Positioning
release-toolkit is developed on a self-hosted Forgejo — source, releases,
issues and CI all at git.frankenbit.de, which is publicly reachable. This
repository is public and anonymously readable: source, issues, tags and releases
are all inspectable at the canonical source, without an account. Projects that
should not be public are private on the same host, per repository.
⚠️ Not every git.frankenbit.de link in these docs resolves for you. A link
into a private repository returns 404 anonymously, which is indistinguishable
from a deleted one. Where that matters, it is called out at the link.
There is also a public mirror, and its releases lag far behind its code.
gitea.com/FrankenBit/release-toolkit
is a push mirror: commits, branches and tags are pushed outward from
git.frankenbit.de on an 8-hour interval. It replaces the Codeberg mirror, which
was retired in #793 because Codeberg does not accept code that is largely
LLM-generated, and this project is.
🔴 A push mirror carries git data only. A Forgejo release is an API object, not git data, so no release crosses by mirroring — each one has to be published there separately. That is the durable fact; the counts below are a snapshot.
2026-09-04 source tags 107 release objects 89 asset-bearing 39 assets 81
mirror tags 107 release objects 39 asset-bearing 39 assets 81
-> 68 tags have no mirrored release object
-> all 68 lack an asset-bearing source release
-> 50 have a source zero-asset release object; 18 have no source object
A backfill run put those 39 there (#1087), and a second identical run changed
nothing — same release ids, same asset ids, same sizes. So the mirror is not merely
populated; the mechanism that populates it is repeatable. The 68 remaining tags are
not one missing-source-release population: 50 have a source release object with zero
assets, while 18 have no source release object at all. There is no asset-bearing
source release to mirror for any of those 68.
A git tag and a release object are independent on this mirror. git checkout <tag> can succeed with no binary behind it, and nothing on either end reports a
problem: the tag resolves, the release page is simply absent.
🔴 This warning deliberately names no example version, and that is the second
lesson rather than a stylistic choice. It named v0.55.0 once and v0.61.1 once;
both were backfilled afterwards, so on both occasions the warning ended up
demonstrating a version that WORKS. A reader checks the example, finds it fine, and
concludes the caveat is historical — twice, from opposite directions, in one week.
The mechanism is permanent; any example of it is a temporary fact about a
particular afternoon. rt readme-pin-check (#1345) now enforces the version this
page recommends against what the mirror can serve, so the invariant has a gate
instead of a paragraph.
⚠️ Check that a release exists for YOUR tag rather than inferring it from the page having releases on it. Every asset-bearing release is now mirrored, but the invariant above is unchanged: nothing crosses by mirroring, so a future release reaches this mirror only when something publishes it there.
⚠️ A cut never originates on a mirror. Releases are cut here and pushed
outward, one-directionally. The mirror runs no CI of its own — our workflows live
in .forgejo/workflows, which Gitea Actions does not read — and a mirror that
cut its own release would push a manifest commit that the next sync overwrites.
It targets Forgejo Actions as its CI runtime — the release flow is a set of
reusable Forgejo Actions workflows you pin by tag on the uses: line. Because
Codeberg is Forgejo, the same workflows run there wherever an Actions runner is
available.
🔑 Those are two different questions and this README used to answer them with one sentence. Where this project lives is a fact about the maintainers' infrastructure. Where the workflows run is a fact about your repository — Forgejo, Gitea or Codeberg, whichever you host on. The second is unaffected by the first.
A companion demo — tic-tac-toe — ran the full toolkit end-to-end on Codeberg on
2026-07-05: fresh-repo bootstrap through the full cut-and-publish flow to a
live v0.1.0 release.
⚠️ That is a dated demonstration, not a live reference, and the release it
produced is gone. The Codeberg original was retired with the rest of the
Codeberg surface in #793. A tic-tac-toe repository survives on the gitea.com
mirror and is publicly readable, but it carries 0 tags and 0 releases
(measured 2026-09-07) — so there is no v0.1.0 object to look at, and no pointer
to it is given here rather than one that resolves to nothing. No CI arm in this
repository exercises a Codeberg target. It is evidence that the flow worked on
that date; nothing here re-checks it.
Runtime compatibility — what the workflows run on, distinct from where the project lives:
- Forgejo Actions — the authored target. Tested on Forgejo 11.x (the maintainers' self-host); not tested on 10.x or older, where the workflow parser may reject syntax the toolkit uses.
- Gitea Actions — compatible in principle (Forgejo forked from Gitea and shares the API surface), not exercised in CI. Feedback welcome via issue.
- Codeberg — demonstrated on 2026-07-05, not continuously tested: the
companion demo above ran the full cut cycle on Codeberg's shared
codeberg-small-lazyrunner. No CI arm here exercises a Codeberg target, so treat this as the strongest evidence in this list below Forgejo Actions — stronger than Gitea's "in principle", weaker than a tested target.
Status
Pin @v0.63.0. That is the version to put on your uses: line — the newest one
the public mirror can actually serve, which is the only thing a uses: reference can
consume. The full example is below.
⚠️ That is the only version on this page, and it is deliberate. The canonical
newest release and the newest one the mirror can serve are different facts, and
either can be ahead: mirroring is a separate manual step, so the mirror trails the
source by any amount — they were four versions apart on the morning of 2026-09-06.
While it trails, uses: must name a version the mirror can serve, and the failure
if it does not is the quiet kind: the git tag resolves there either way, so an
unmirrored reference is accepted, the checkout succeeds, and fetch-rt then finds
no asset — your first failure is at bootstrap, with a green reference behind you.
🔑 Why the pin above survives and a "latest version" line does not: only one of them
is machine-KEPT. rt readme-pin-check grades the @vX.Y.Z pins on this page against
what the mirror can serve, and --fix sets them from the same computation — so the pin
above cannot go stale without a gate saying so on the next PR. ⚠️ A version written
in prose has no such gate, is true when written, goes false the moment the mirror
lags, and cannot tell a reader how old it is; a hand-kept one on this page went stale
in public twice. The verb grades pins, not paragraphs, and it does not run when you
read this. For the live answer, the mirror's
releases cannot be stale.
🔴 AND THE RULE IS ABOUT CURRENCY CLAIMS, NOT ABOUT VERSION NUMBERS. A number
naming a moment in the past cannot go false — a retirement, an earliest-supported
tag, a dated observation. A number a reader would ACT on can, and that is the one
a gate has to keep. ✅ The test, so it is not a matter of taste: does it carry its
own date, or name a version already superseded? Then it is historical. Would a reader
put it in their own file? Then it is a currency claim. ⚠️ Read as "no numbers"
this rule deletes true sentences: the integration guide carries dozens of descriptive
versions and exactly ONE of them claimed currency. 📌 Worked examples live in
internal/readmepin/readme_status_test.go, beside the arm that enforces this — they
are numbers, and this section is the one place they may not be.
Pre-1.0 and actively developed: the architecture has settled — recent releases cut cleanly — and a pre-1.0 readiness sweep is underway (real-world adoption to surface friction + a public-surface audit) before committing to 1.0's backwards-compatibility guarantees.
What pre-1.0 means for you: the toolkit is stable in practice — it cuts its own
releases and drives tmux-tell's (private repository — not anonymously readable).
Config keys and workflow inputs may still shift between minor versions
(0.x → 0.(x+1)), but each such change is called out explicitly in the
CHANGELOG. Pin to a tag your forge can reach — the version named at the top of this section.
What pinning @v1.x will commit to — and what it deliberately won't — is
named surface-by-surface in docs/VERSIONING.md.
What it does
-
Cuts releases from a single config. You dispatch (or push) a release; the toolkit computes the SemVer bump, transitions your
CHANGELOG.md(moves[Unreleased]into the new version section), bumps your version files, and opens a release-prep PR. You review and merge to fire the cut. -
Separates release-note content from bump signals.
- Conventional commits (
feat:/fix:/feat!:/ …) are read straight from git log to determine the version bump. A subject alone is not copied into adopter-facing notes. changelog.d/<id>.<kind>.mdfragments are hand-written release-note prose for changes that need an entry beyond the bump signal.
Use a conventional commit for the bump, and add a fragment when the change needs release-note content. The toolkit merges explicit note sources without duplicating section headings.
- Conventional commits (
-
Catches release-prep mistakes in CI. A reusable check runs on every PR and flags the usual foot-guns: version numbers out of sync across files, a missing
## [Unreleased]heading, or a manifest left behind the latest tag. -
Keeps your tag list tidy. The pre-release (
-rc.N) tags a cut creates are pruned automatically once the release ships, so they don't accumulate (v0.12.0+). -
Runs the workflow YAML and its scripts at one version. When you pin the toolkit at a tag, the reusable workflow and the scripts it calls are always that same version — no drift between the two (v0.13.0+).
-
Reviews every release before it is cut. The flow is multi-stage — dispatch → prep PR → cut → publish → deploy — and the operator gate is the prep PR: the version bump, changelog and manifest are reviewed and merged before anything is tagged. The cut itself publishes automatically. Adopters who want a manual publish step can set
publish_mode: 'draft'.
Bump signals
The toolkit decides the SemVer bump from up to four sources, and the highest
bump wins — so a single feat!: or BREAKING CHANGE anywhere forces a major:
| Source | How you set it | Drives |
|---|---|---|
| Conventional-commit subject | feat: / fix: / feat!: (or BREAKING CHANGE:) in commit messages |
minor / patch / major, auto-detected from git log |
| Fragment body | a BREAKING CHANGE marker in a changelog.d/ fragment (or a removed-kind fragment) |
major |
| Workflow override | bump_override: major|minor|patch dispatch input |
forces that level explicitly |
| Rolling-PR label | bump/major / bump/minor / bump/patch on the prep PR (Forgejo exclusive-label group — picking one auto-removes the others) |
that level |
Set nothing and the bump is inferred from your commits and fragments. Set an override and it takes precedence. Either way, the resolved bump is shown in the prep PR before you merge.
Quick start (consumer adoption)
Four steps — full guide in docs/integration.md.
Runner requirements: toolkit-owned gates use the measured
gorunner image, which carries Go,golangci-lint,bats,shellcheck,jq,curl, and the Pythonyqwrapper; the optional LikeC4 build usesplaywrightwith Graphviz. The reusable workflows still accept a consumer-selectedruns_onlabel and retain a fail-loud install fallback for non-standard images. Details in integration.md § Runner requirements.Fresh repos need zero manual setup — the toolkit assumes a virtual
v0.0.0anchor and walks from your initial commit. First cut isv0.0.1(fix:only),v0.1.0(anyfeat:), ormode=noopif onlychore:/docs:/refactor:commits exist (add afeat:/fix:, or drop abump/*label to force the cut). Details in integration.md § Bootstrapping a new consumer repo.
-
Add
release-toolkit.ymlat your repo root:version: 1 release_type: node # or go, python, multi version_files: - package.json - VERSION changelog: CHANGELOG.md tag_format: 'v{version}' -
Seed the fragment directory: add
changelog.d/.keep. -
Add a release workflow referencing the reusable (pin the tag):
# .forgejo/workflows/release.yml name: release on: push: branches: [main] paths-ignore: ['.release-toolkit-manifest.json'] # don't re-fire the cut on its own manifest commit workflow_dispatch: # retained as a fast-path for manual/emergency cuts inputs: bump_override: { type: string, default: auto } jobs: release: # ABSOLUTE URL, because the toolkit is not on your forge. A bare # `frankenbit/release-toolkit/...` reference resolves against YOUR OWN # instance and 404s unless you host a copy of the toolkit there. # # THE PATH BELOW DEPENDS ON YOUR FORGE, not on ours. `.forgejo/workflows/` # is for Forgejo and Codeberg. On GITEA, use `.gitea/workflows/` instead. # Both paths exist in the mirror and both resolve, so the wrong one does # NOT 404. The caller registers, validates, and the job never runs — no # error, no red, no log line. See integration.md, "Your forge decides". uses: https://gitea.com/FrankenBit/release-toolkit/.forgejo/workflows/reusable-release.yml@v0.63.0 with: runs_on: go # your Forgejo runner label bump_override: ${{ inputs.bump_override }} # publish_mode omitted — defaults to 'immediate' (the cut publishes itself). # Adopters who want a manual publish step can add `publish_mode: 'draft'`, # which creates the release as a draft for you to click Publish. secrets: inherit # REQUIRED — the one adopter measured without it has a release PR that has never received any CI. Also enables the release-bot path. See integration.md.On a push to
mainthe toolkit opens or updates a rolling release-prep PR; merging that PR fires the cut. (workflow_dispatchstays available for a manual cut.)Token setup is optional. The default needs no token — after each cut the toolkit opens a small manifest PR for you to merge. To make cuts fully autonomous on a protected
main, see token setup in integration.md. -
Document your commit + fragment conventions for contributors — templates in docs/conventions.md, plus the skim-scan fragment style guide in docs/fragment-style.md for the shape a good fragment takes (contributors point their own PR reviewers at this).
Why this exists
Every project reinvents release engineering: the version-bump logic, the CHANGELOG dance, the tag-vs-manifest drift checks, the "did I forget to publish?" gates. release-toolkit extracts that into shared, versioned building blocks you reference by tag — so a new project starts with a complete, working release pipeline on day one instead of copy-pasting and maintaining the machinery itself.
The design is release-please-inspired — manifest-vs-tag CI, configurable version-file handling, and composable release-note sources — reworked natively for Forgejo, where release-please (a GitHub product) isn't a clean drop-in. That Forgejo-native fit is the niche. It's pre-1.0 and deliberately scoped: the shared release-please architecture, not its full language and monorepo breadth (see Status).
Architecture and design
- Architecture overview — how the toolkit is built at a glance: the reusable workflows, the cut lifecycle, and the design tenets (each links to its decision record).
- Consumer integration guide — the full adoption walkthrough.
- Conventions — commit-subject and fragment formats.
- Fragment style guide — the skim-scan shape a good fragment takes, plus the length-warn CI signal that surfaces essay-length drift at PR-review time.
- Operations — running and troubleshooting the flow.
- Architecture decisions — the design rationale: the multi-stage operator gate (ADR-0003), push-triggered prep (ADR-0004), and the release-note source model (ADR-0011; ADR-0002 is its historical predecessor, with ADR-0006 recording the RNA boundary).
License
MIT — see LICENSE.