251 lines
6.6 KiB
Go
251 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// Gitea and GitHub expose the same release JSON, only under different API
|
|
// roots and with different auth headers.
|
|
type forgeCtx struct {
|
|
base string // scheme, host and any sub-path Gitea is mounted under
|
|
owner string
|
|
repo string
|
|
forge string // "github" or "gitea"
|
|
token string
|
|
api string // .../repos/<owner>/<repo>
|
|
}
|
|
|
|
type Asset struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Size int64 `json:"size"`
|
|
BrowserDownloadURL string `json:"browser_download_url"`
|
|
Digest string `json:"digest"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type Release struct {
|
|
TagName string `json:"tag_name"`
|
|
HTMLURL string `json:"html_url"`
|
|
PublishedAt string `json:"published_at"`
|
|
Draft bool `json:"draft"`
|
|
Prerelease bool `json:"prerelease"`
|
|
Assets []Asset `json:"assets"`
|
|
}
|
|
|
|
func newForgeCtx(spec *Spec) (*forgeCtx, error) {
|
|
base, owner, repo, err := parseRepo(spec.Repo)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
forge, err := detectForge(base, spec.Forge)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
c := &forgeCtx{
|
|
base: base,
|
|
owner: owner,
|
|
repo: repo,
|
|
forge: forge,
|
|
token: resolveToken(forge, spec.Token),
|
|
}
|
|
c.api = apiBase(base, forge) + "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(repo)
|
|
return c, nil
|
|
}
|
|
|
|
func parseRepo(repo string) (base, owner, name string, err error) {
|
|
repo = strings.TrimRight(repo, "/")
|
|
repo = strings.TrimSuffix(repo, ".git")
|
|
if !strings.HasPrefix(repo, "http://") && !strings.HasPrefix(repo, "https://") {
|
|
repo = "https://" + repo
|
|
}
|
|
u, e := url.Parse(repo)
|
|
if e != nil || u.Host == "" {
|
|
return "", "", "", fmt.Errorf("cannot parse repository URL: %s", repo)
|
|
}
|
|
// Anything before <owner>/<repo> is a base path (Gitea under a sub-path).
|
|
seg := strings.FieldsFunc(u.Path, func(r rune) bool { return r == '/' })
|
|
if len(seg) < 2 {
|
|
return "", "", "", fmt.Errorf("repository URL needs <owner>/<repo>: %s", repo)
|
|
}
|
|
owner, name = seg[len(seg)-2], seg[len(seg)-1]
|
|
base = u.Scheme + "://" + u.Host
|
|
if prefix := seg[:len(seg)-2]; len(prefix) > 0 {
|
|
base += "/" + strings.Join(prefix, "/")
|
|
}
|
|
return base, owner, name, nil
|
|
}
|
|
|
|
var githubHost = regexp.MustCompile(`(^|\.)github\.com$`)
|
|
|
|
func hostOf(base string) string {
|
|
u, err := url.Parse(base)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.ToLower(u.Hostname())
|
|
}
|
|
|
|
func detectForge(base, override string) (string, error) {
|
|
if override != "" {
|
|
f := strings.ToLower(override)
|
|
if f != "github" && f != "gitea" {
|
|
return "", fmt.Errorf("unknown forge %q (use github or gitea)", override)
|
|
}
|
|
return f, nil
|
|
}
|
|
if githubHost.MatchString(hostOf(base)) {
|
|
return "github", nil
|
|
}
|
|
return "gitea", nil
|
|
}
|
|
|
|
func apiBase(base, forge string) string {
|
|
if forge != "github" {
|
|
return base + "/api/v1"
|
|
}
|
|
if githubHost.MatchString(hostOf(base)) {
|
|
return "https://api.github.com"
|
|
}
|
|
return base + "/api/v3" // GitHub Enterprise
|
|
}
|
|
|
|
func resolveToken(forge, explicit string) string {
|
|
if explicit != "" {
|
|
return explicit
|
|
}
|
|
env := []string{"UPD_TOKEN", "GITEA_TOKEN"}
|
|
if forge == "github" {
|
|
env = []string{"UPD_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"}
|
|
}
|
|
for _, k := range env {
|
|
if v := os.Getenv(k); v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (c *forgeCtx) authHeaders() map[string]string {
|
|
if c.token == "" {
|
|
return map[string]string{}
|
|
}
|
|
// GitHub wants "Bearer", Gitea wants "token".
|
|
if c.forge == "github" {
|
|
return map[string]string{"Authorization": "Bearer " + c.token}
|
|
}
|
|
return map[string]string{"Authorization": "token " + c.token}
|
|
}
|
|
|
|
// Private GitHub assets are only reachable through the API URL; the browser
|
|
// URL redirects to storage, where the Authorization header is dropped.
|
|
func (c *forgeCtx) assetURL(a *Asset) (string, map[string]string) {
|
|
if c.forge == "github" && c.token != "" && a.ID != 0 {
|
|
return fmt.Sprintf("%s/releases/assets/%d", c.api, a.ID),
|
|
map[string]string{"Accept": "application/octet-stream"}
|
|
}
|
|
return a.BrowserDownloadURL, map[string]string{}
|
|
}
|
|
|
|
func (c *forgeCtx) releasesURL() string {
|
|
if c.forge == "github" {
|
|
return c.api + "/releases?per_page=50"
|
|
}
|
|
return c.api + "/releases?limit=50&draft=false"
|
|
}
|
|
|
|
// ==============================================================================
|
|
// Release selection
|
|
// ==============================================================================
|
|
|
|
func (c *forgeCtx) fetchRelease(spec *Spec) (*Release, []Release, error) {
|
|
// A specific tag: both forges have a direct endpoint. Fall back to the list
|
|
// so "26.5.6" also finds a tag named "v26.5.6".
|
|
if spec.Tag != "" {
|
|
body, err := c.apiGet(c.api+"/releases/tags/"+url.PathEscape(spec.Tag), true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if body != nil {
|
|
var rel Release
|
|
if json.Unmarshal(body, &rel) == nil && rel.TagName != "" {
|
|
return &rel, nil, nil
|
|
}
|
|
}
|
|
list, err := c.releaseList()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
for i := range list {
|
|
if list[i].TagName == spec.Tag {
|
|
return &list[i], nil, nil
|
|
}
|
|
}
|
|
for i := range list {
|
|
if normVer(list[i].TagName) == normVer(spec.Tag) {
|
|
return &list[i], nil, nil
|
|
}
|
|
}
|
|
return nil, nil, fmt.Errorf("release %q not found (--list shows all)", spec.Tag)
|
|
}
|
|
|
|
// The plain case is one request: the forge already knows its latest
|
|
// non-draft, non-prerelease release.
|
|
if !spec.Pre && !opt.list {
|
|
body, err := c.apiGet(c.api+"/releases/latest", true)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if body != nil {
|
|
var rel Release
|
|
if json.Unmarshal(body, &rel) == nil && rel.TagName != "" {
|
|
return &rel, nil, nil
|
|
}
|
|
}
|
|
verbose("no /releases/latest, falling back to the release list")
|
|
}
|
|
|
|
list, err := c.releaseList()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(list) == 0 {
|
|
return nil, nil, fmt.Errorf("no releases found in %s/%s", c.owner, c.repo)
|
|
}
|
|
if opt.list {
|
|
return nil, list, nil
|
|
}
|
|
for i := range list {
|
|
if !list[i].Draft && (spec.Pre || !list[i].Prerelease) {
|
|
return &list[i], nil, nil
|
|
}
|
|
}
|
|
return nil, nil, fmt.Errorf("no suitable release found (try --pre)")
|
|
}
|
|
|
|
func (c *forgeCtx) releaseList() ([]Release, error) {
|
|
body, err := c.apiGet(c.releasesURL(), false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var list []Release
|
|
if err := json.Unmarshal(body, &list); err != nil {
|
|
return nil, fmt.Errorf("release list from %s is not an array: %w", c.releasesURL(), err)
|
|
}
|
|
return list, nil
|
|
}
|
|
|
|
func normVer(v string) string {
|
|
v = strings.TrimSpace(v)
|
|
if v != "" && (v[0] == 'v' || v[0] == 'V') {
|
|
v = v[1:]
|
|
}
|
|
return strings.TrimSpace(v)
|
|
}
|