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
+28 -15
View File
@@ -162,15 +162,18 @@ func (r *remoteAPI) authUser() (string, error) {
return "", fmt.Errorf("could not determine remote user")
}
// repoPath is the API path of one repository. GitLab addresses a project by its
// URL-encoded "owner/repo" path, the others by two path elements.
func (r *remoteAPI) repoPath(owner, repo string) string {
if r.kind == kindGitLab {
return r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo)
}
return r.apiRoot() + "/repos/" + owner + "/" + repo
}
// repoExists reports whether owner/repo already exists on the server.
func (r *remoteAPI) repoExists(owner, repo string) (bool, error) {
var ep string
if r.kind == kindGitLab {
ep = r.apiRoot() + "/projects/" + url.PathEscape(owner+"/"+repo)
} else {
ep = r.apiRoot() + "/repos/" + owner + "/" + repo
}
code, data, err := r.do("GET", ep, nil)
code, data, err := r.do("GET", r.repoPath(owner, repo), nil)
if err != nil {
return false, err
}
@@ -338,15 +341,9 @@ func pushToRemote(t RemoteTarget, repo, description string) bool {
// keep a credential-free git remote named after the target
web := api.repoWebURL(owner, repo)
if _, err := gitCapture(DIR, "remote", "get-url", t.Name); err == nil {
gitOK(DIR, "remote", "set-url", t.Name, web)
} else {
gitOK(DIR, "remote", "add", t.Name, web)
}
ensureGitRemote(t.Name, web)
// authenticate the push with a one-shot Basic auth header, so the token is
// neither persisted in the repository's git config nor visible in `ps`
header := "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(owner+":"+api.key))
header := api.pushHeader(owner)
if !gitPushHeader(DIR, t.Name, header, "--all") {
return false
}
@@ -355,6 +352,22 @@ func pushToRemote(t RemoteTarget, repo, description string) bool {
return true
}
// ensureGitRemote points a credential-free git remote named name at web,
// adding it when the repository does not have it yet.
func ensureGitRemote(name, web string) bool {
if _, err := gitCapture(DIR, "remote", "get-url", name); err == nil {
return gitOK(DIR, "remote", "set-url", name, web)
}
return gitOK(DIR, "remote", "add", name, web)
}
// pushHeader builds the one-shot HTTP Basic auth header used for pushes, so the
// token is neither persisted in the repository's git config nor visible in `ps`.
func (r *remoteAPI) pushHeader(owner string) string {
return "Authorization: Basic " +
base64.StdEncoding.EncodeToString([]byte(owner+":"+r.key))
}
// gitPushHeader runs `git push <remote> <args...>` with an extra HTTP auth
// header, disabling interactive credential prompts.
//