No description
  • Go 98.5%
  • Just 1.5%
Find a file
Viktor Varland d4a2db67d7
All checks were successful
ci/woodpecker/push/build Pipeline was successful
feat: an artifact can carry a second binary
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.
2026-08-31 14:38:47 +02:00
.just feat: install the suite to the XDG bin dir 2026-08-01 23:42:32 +02:00
.woodpecker feat!: retire scripts/release-note.sh from the apps 2026-08-08 09:25:46 +02:00
internal feat: an artifact can carry a second binary 2026-08-31 14:38:47 +02:00
.gitignore chore: ignore local agent settings 2026-08-01 23:55:36 +02:00
CHANGELOG.md docs: changelog for v0.4.0 2026-08-29 22:58:38 +02:00
config.example.toml feat: an artifact can carry a second binary 2026-08-31 14:38:47 +02:00
go.mod feat: add shell completion 2026-08-28 16:38:15 +02:00
go.sum feat: add shell completion 2026-08-28 16:38:15 +02:00
justfile feat: install the suite to the XDG bin dir 2026-08-01 23:42:32 +02:00
main.go feat: an artifact can carry a second binary 2026-08-31 14:38:47 +02:00
README.md feat: let init register the repo it is run in 2026-08-29 20:13:58 +02:00

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 whose changelog/release recipes delegate to wrangle, and which defines the suite-wide install prefix, imported into the app's justfile.

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.