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// } 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 / 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 /: %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) }