Add release: publish tagged releases on the mirror servers

`release [@name ...] <tag> [notes]` does the whole chain in one step —
create the annotated tag, push it to the internal server, then push it to
each selected mirror and turn it into a release object there. Target
selection reuses pushremote's @name mechanism, so the two behave alike.

Notes are generated when none are given: the tag's own annotation when it
carries more than the default, otherwise the commit subjects since the
previous tag, capped at 50 lines. `tag add v1.0 "why this exists"` now
takes a message, which is what that fallback reads; before, the
annotation was always just the tag name.

Tags ending in -rc/-alpha/-beta/-pre are marked as pre-releases on Gitea
and GitHub. Releasing the same tag twice updates the existing release;
a tag that already points at a different commit stops the command, since
moving a published tag makes one version mean different things per
server. A repository that is not on the mirror yet is reported instead of
being created as a side effect.

Binary assets are deliberately out of scope: Gitea attaches them to the
release, GitHub uses a separate upload host, and GitLab does not host
them at all but wants a link into its package registry.

The providers differ in path shape and field names -- GitLab addresses
projects by URL-encoded path, calls the notes "description" and has no
pre-release flag -- so this comes with a recording httptest stand-in that
asserts the exact requests for all three. That harness also covers
authUser, repoExists and the auth header forms, which had no test at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 15:47:05 +02:00
co-authored by Claude Opus 5
parent fa44a4056a
commit 915ef1783a
9 changed files with 716 additions and 25 deletions
+13 -2
View File
@@ -285,6 +285,9 @@ func runCommandDepth(line string, depth int) bool {
case "pushremote": // mirror the repo to a public git server via its API
handlePushRemote(strings.Join(fields[1:], " "))
case "release": // tag a commit and publish it as a release on the mirrors
handleRelease(strings.Join(fields[1:], " "))
case "edit": // interactively edit the last N commits
if !requireRepo() {
break
@@ -516,7 +519,14 @@ func runCommandDepth(line string, depth int) bool {
sub := word(words, 1)
switch {
case sub == "add" && word(words, 2) != "":
if gitOK(DIR, "tag", "-a", words[2], "-m", words[2]) {
// `tag add v1.0 why this exists` annotates with that text; without
// one the tag name is the message, as before. The annotation is
// what `release` falls back to for its notes.
msg := words[2]
if len(fields) > 3 {
msg = strings.Join(fields[3:], " ")
}
if gitOK(DIR, "tag", "-a", words[2], "-m", msg) {
gitOK(DIR, "push", "origin", words[2])
}
case sub == "checkout" && word(words, 2) != "":
@@ -697,6 +707,7 @@ var helpItems = []struct{ cmd, desc string }{
{"init", "make new repository from current directory"},
{"push [comment]", "push changes to git server"},
{"pushremote [@name] [desc]", "mirror repo to the public server(s) (gitea/github/gitlab)"},
{"release <tag> [notes]", "tag and publish a release on the public server(s)"},
{"pull", "pull changes from git server"},
{"fetch", "fetch changes from git server"},
{"status [-a]", "short git status (-a: overview of all projects)"},
@@ -714,7 +725,7 @@ var helpItems = []struct{ cmd, desc string }{
{"login", "connect to git server"},
{"count", "count lines in project"},
{"tag", "show tags"},
{"tag add <tag>", "add tag"},
{"tag add <tag> [msg]", "add tag (msg becomes the annotation)"},
{"tag checkout <tag>", "checkout tag"},
{"tag delete <tag>", "delete tag"},
{"alias [name [cmd]]", "list, show or define an alias ($1..$N, $* args)"},