- Go 98.5%
- Just 1.5%
|
All checks were successful
ci/woodpecker/push/build Pipeline was successful
claudia's release tarball held claudia and nothing else, but claudia does not work alone: it resolves claudia-shim off PATH as the authorized peer for secret substitution, and `just install` puts both in one directory precisely so a shim from elsewhere cannot take that role. `claudia update self` installs every file the archive holds, so an artifact with one binary in it updates half a pair and leaves a version-skewed shim behind. release_extras names the others, as packages under cmd/. They are built into the same dist directory the tar picks up, and deliberately carry no version stamp: the artifact's version is the main binary's, and a helper answering that question differently is a second source of truth. A name with no package under cmd/ is refused while init runs, rather than by `go build` after the tag is pushed. |
||
|---|---|---|
| .just | ||
| .woodpecker | ||
| internal | ||
| .gitignore | ||
| CHANGELOG.md | ||
| config.example.toml | ||
| go.mod | ||
| go.sum | ||
| justfile | ||
| main.go | ||
| README.md | ||
wrangle
Manage the vlv app suite from one place. wrangle multiplexes build and release
commands over the apps in its registry, derives each release version from
conventional commits, and propagates the house-style release scripts so they
stop drifting copy-by-copy across repos.
It exists because every app carried a byte-identical scripts/changelog.sh and a
near-identical scripts/release-note.sh plus the same changelog/release
justfile recipes. wrangle owns that logic once, in Go: the apps carry no release
scripts at all, only the pipelines and a justfile fragment that delegate here.
Registry
wrangle manages exactly the apps listed in ~/.config/wrangle/config.toml
(honors $XDG_CONFIG_HOME; override with --config). An app is opted in by
name, not by sitting in a scanned directory. See config.example.toml.
workspace = "~/dev/vlv" # apps live at <workspace>/<name>
apps = ["dymo", "jira", "skiff", "todo", "..."]
You don't have to edit it by hand to add a repo: wrangle init, run in a
checkout, registers it (see init). The registry stays explicit —
nothing is managed for merely sitting in the workspace — but typing the command
in the repo is the opt-in, and the file it writes is still yours to edit.
Commands
| Command | What it does |
|---|---|
wrangle list (ls) |
Table of apps: version, clean/dirty, drift, path |
wrangle update [app…] (up) |
Switch to the default branch and fast-forward it from origin |
wrangle release [app…] |
Derive the next version from commits, write the changelog, commit, tag (and --push) |
wrangle push [app…] |
Publish the current branch and any new tags to origin |
wrangle changelog [app…] |
Write the next CHANGELOG.md section only |
wrangle build [app…] |
go build with the version stamped in |
wrangle install [app…] |
Build and install the app's binary via its just install recipe |
wrangle init [app…] |
Register the repo if it is new, and stamp the Woodpecker pipelines and a delegating justfile fragment |
Selecting apps:
- Named:
wrangle release skiff todo - All:
wrangle release --all - Inferred: with no app, run inside an app's checkout and wrangle picks it —
so
just release(which delegates here) works from any app directory.
Flags come before positional app names (
wrangle release --dry-run skiff, not… skiff --dry-run) — the shared CLI framework stops flag parsing at the first positional, as everywhere in the suite.
Version derivation
The bump is read from the conventional commits since the last vX.Y.Z tag:
- a breaking change (
feat!:,<type>!:) → major - a
feat:→ minor - anything else (fix, docs, chore, …) → patch
- no commits since the tag → nothing to release (skipped under
--all)
Pre-1.0 (a v0.x tag) deviates in one place, so an app never auto-jumps to
1.0.0: a breaking change bumps the minor instead of the major. A feat:
still bumps the minor, which collides feat: and feat!: — honest for a 0.x,
where a minor already means "this may break".
Override any of this with --major / --minor / --patch, which are taken
literally as plain semver — --major on a v0.x tag cuts v1.0.0, and is how
you declare the API stable. Or pin an exact version with --version v1.4.0. Use
--dry-run to preview.
A release is always cut from the default branch. A checkout sitting on a
feature branch is switched to it first (refused if tracked files are dirty), so
what gets tagged never depends on where the checkout happened to be parked —
and --dry-run previews that branch by ref without moving anything. If the
branch is behind origin, the release stops and points at wrangle update
rather than tagging a commit that is not origin's tip.
By default a release stays local — the changelog commit and tag are created
but not published, and wrangle prints the wrangle push command. Pass --push
to push the branch and the new tag to origin once tagging succeeds. If the push
fails, the commit and tag remain locally and wrangle reports the retry command,
so a release is never half-applied.
To push on every release without typing the flag, set push = true in the
registry (or push = false under a [app.<name>] table to opt one app out). An
explicit --push / --push=false on the command line always wins over the
config default.
Release notes
The forge release note — the link to the CHANGELOG section a release just wrote — is written into the annotated tag, as the body under the version subject:
$ git tag -l --format='%(contents:body)' v1.2.0
See [CHANGELOG](https://git.meatbag.se/varl/skiff/src/branch/main/CHANGELOG.md#v1-2-0-2026-08-08)
The release pipeline reads it back off the tag. This is what retired the last
script the apps carried: scripts/release-note.sh had to recover the version
and date from CI_COMMIT_TAG and git log, then derive the forge URL from
origin with sed, in a file copied into every repo — to rebuild a string
wrangle already had at the moment it tagged. The anchor is derived from the
changelog heading itself, so the two cannot disagree.
A tag pushed by hand carries no note, and neither does one in a repo whose
origin cannot be parsed; releasing is not blocked over it. The pipeline
falls back to a plain link to CHANGELOG.md with no anchor.
update
wrangle update brings a checkout to the tip of its default branch — the
branch origin/HEAD points at, asked per repo rather than assumed to be main.
A checkout parked on a feature branch is moved there first, then fast-forwarded:
wrangle update --all
✔ skiff: updated main from feature/x, 3 commits (a1b2c3d..e4f5a6b)
todo: already current
Three things stop it, each reported rather than resolved on your behalf: an
unreachable origin, uncommitted changes to tracked files that a branch
switch would carry along (untracked files ride harmlessly and don't count), and
a branch that has diverged from origin. Merges are --ff-only; wrangle
moves a branch forward, it never reconciles histories for you.
push
Because a release stays local by default, unpublished work accumulates one repo
at a time — wrangle push is what drains it:
wrangle push --all --dry-run
▸ skiff: would push 2 commits and tag v1.2.0 on main
todo: nothing to push
It pushes the current branch plus every version tag reachable from it that
origin does not have, in a single git push so commits and the tags naming them
publish together. A repo origin is already up to date with is skipped, so
--all is safe to run over the whole suite, and re-running is a no-op.
Two states stop a push rather than let git fail halfway: a branch that has diverged from origin (reconcile first — wrangle never force-pushes), and an unreachable origin. Uncommitted changes are only a warning; they were never going to be part of a push.
wrangle list shows the same backlog offline, as N unpushed in the status
column, measured against the remote-tracking refs as they stand.
install
wrangle install <app> runs the app's house-style just install recipe —
building the app and copying its binary into the per-user bin dir. It is a thin
multiplexer over just install so wrangle install --all installs the whole
suite in one go.
Where that lands is suite-wide policy, not per-app: the managed justfile fragment defines
prefix := env('XDG_BIN_HOME', env('HOME') / '.local/bin')
so every app installs to ~/.local/bin (or $XDG_BIN_HOME), no sudo and no
/usr/local/bin. Each app's own install recipe still decides what it
installs — a second binary, a helper script — and consumes {{prefix}} for the
destination. Override for a one-off with just prefix=/opt/bin install.
init
wrangle init is how a repo joins the suite. Run it in a fresh checkout and it
registers the repo first, then provisions it:
cd ~/dev/vlv/newapp && wrangle init
✔ newapp: registered as an app in ~/.config/wrangle/config.toml
▸ newapp: created .woodpecker/build.yml
▸ newapp: created .woodpecker/release.yml
▸ newapp: created .just/wrangle.just
▸ newapp: updated justfile (added import)
The entry is derived from the checkout: the name is the directory's, and it is
an app or a lib by whether the repo builds a binary — a package main
at the root or under cmd/. A checkout outside the workspace gets a
[app.<name>] path override so it is still found. With no registry file at all,
one is written, its workspace set to the checkout's parent. Only the array
line (and any override) is touched: the file is edited as text, so its comments
and layout survive.
Two things it will not adopt: a directory that is not a git checkout, and a repo
with no go.mod — the pipelines it stamps are Go pipelines, so wrangle init
typed in the wrong directory says so rather than quietly registering it. A name
the registry already gives to a different checkout is refused too. --all still
means "every repo already registered"; it never scans the workspace.
It writes, from wrangle's embedded templates:
.woodpecker/build.yml— the branch pipeline: test and compile, publishing nothing. Every app and lib gets one..woodpecker/release.yml— the tag pipeline: the one that publishes. Apps only; for a library the tag is the release, with no artifact to attach..just/wrangle.just— a fragment whosechangelog/releaserecipes delegate to wrangle, and which defines the suite-wide installprefix, imported into the app'sjustfile.
The pipelines are rendered rather than stamped byte-for-byte, because two
things about a release cannot be derived: the artifact is not always named
after the repo, and the platform set is a choice. Both come from the registry —
release_binary and release_platforms — and default to the app's own name and
linux/amd64. Everything else is worked out from the checkout: the package is
./cmd/<binary> when that directory exists and . when it does not. The same
platform list drives the release artifacts and the compile check in
build.yml, so a platform you ship is one that breaks the branch build first
rather than during a release.
With --prune it also retires what those replace: the inline changelog/
release recipes in the justfile, and the superseded scripts/changelog.sh and
scripts/release-note.sh.
--commit records the result, one commit per app, naming only the paths
provisioning wrote — so an app you were midway through editing keeps its staged
work out of a commit about CI files. A run that changed nothing makes no commit,
which matters for init --all --commit: most apps will already be current when
a template moves, and an empty commit in twelve histories is noise. Deletions
from --prune go in the same commit as what replaced them.
Re-running init is idempotent; wrangle list flags any app whose managed
files have drifted from the templates — the pipelines included, since a CI fix
applied to eleven of twelve repos looks exactly like one applied to all twelve
until the twelfth releases.