Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 123 additions & 9 deletions internal/cmd/plugin/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,30 @@ func pluginAssetName(pluginName, _ string) (string, error) {
return fmt.Sprintf("%s_%s_%s.%s", pluginName, osName, arch, ext), nil
}

// fetchLatestTag resolves the latest GitHub Release tag via redirect.
// It issues a HEAD request to releases/latest and parses the tag from the final URL.
// FetchLatestTag resolves the latest release tag for a GitHub owner/repo.
// Exported so callers outside this package can pass it to pluginstore.RefreshIndex.
func FetchLatestTag(ctx context.Context, owner, repo string) (string, error) {
return fetchLatestTag(ctx, owner, repo)
}

var githubAPIBaseURL = "https://api.github.com"

const releaseTagFormat = "a semantic version such as v1.2.3 or v1.2.3-rc.1"

// fetchLatestTag returns the tag GitHub marks as the latest release. GitHub
// never marks a pre-release as latest, so a repository that has published only
// pre-releases falls back to its highest pre-release tag.
func fetchLatestTag(ctx context.Context, owner, repo string) (string, error) {
tag, err := fetchLatestStableTag(ctx, owner, repo)
if err != nil || tag != "" {
return tag, err
}
return fetchNewestReleaseTag(ctx, owner, repo)
}

// fetchLatestStableTag follows the releases/latest redirect and returns the tag
// it lands on, or "" when the repository has no release marked as latest.
func fetchLatestStableTag(ctx context.Context, owner, repo string) (string, error) {
url := fmt.Sprintf("https://github.com/%s/%s/releases/latest", owner, repo)

client := &http.Client{
Expand All @@ -122,18 +137,117 @@ func fetchLatestTag(ctx context.Context, owner, repo string) (string, error) {
}
defer resp.Body.Close()

// After following redirect, the final URL has the tag at the end.
finalURL := resp.Request.URL.String()
tag := filepath.Base(finalURL)
if tag == "" || tag == "." || tag == "/" {
return "", fmt.Errorf("could not parse release tag from URL %q", finalURL)
if resp.StatusCode == http.StatusNotFound {
return "", fmt.Errorf("no GitHub repository %s/%s with published releases was found", owner, repo)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("fetch latest release tag: HTTP %s", resp.Status)
}
return tagFromLatestReleaseURL(resp.Request.URL.String())
}

// tagFromLatestReleaseURL extracts the tag from the URL that releases/latest
// redirects to. GitHub redirects to .../releases/tag/<tag> when a latest
// release exists and to .../releases when none does; the latter returns "".
func tagFromLatestReleaseURL(rawURL string) (string, error) {
u, err := url.Parse(rawURL)
if err != nil {
return "", fmt.Errorf("could not parse release tag from URL %q: %w", rawURL, err)
}
if strings.HasSuffix(strings.TrimSuffix(u.EscapedPath(), "/"), "/releases") {
return "", nil
}
_, escapedTag, ok := strings.Cut(u.EscapedPath(), "/releases/tag/")
if !ok || escapedTag == "" {
return "", fmt.Errorf("could not parse release tag from URL %q", rawURL)
}
tag, err := url.PathUnescape(escapedTag)
if err != nil {
return "", fmt.Errorf("could not parse release tag from URL %q: %w", rawURL, err)
}
if !strings.HasPrefix(tag, "v") {
return "", fmt.Errorf("unexpected release tag format %q (expected vX.Y.Z)", tag)
if !semver.IsValid(tag) {
return "", fmt.Errorf("release tag %q is not a semantic version; expected %s", tag, releaseTagFormat)
}
return tag, nil
}

// githubRelease is the subset of the GitHub REST release object datumctl reads.
type githubRelease struct {
TagName string `json:"tag_name"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}

// fetchNewestReleaseTag lists the repository's releases through the GitHub API
// and returns the highest semantic-version tag, pre-releases included.
func fetchNewestReleaseTag(ctx context.Context, owner, repo string) (string, error) {
listURL := fmt.Sprintf("%s/repos/%s/%s/releases?per_page=100", githubAPIBaseURL, owner, repo)
pinHint := fmt.Sprintf("install a specific release with %s/%s@<tag>", owner, repo)

client := &http.Client{Timeout: 15 * time.Second, CheckRedirect: pluginstore.SafeCheckRedirect}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, listURL, nil)
if err != nil {
return "", err
}
req.Header.Set("User-Agent", "datumctl-plugin-install")
req.Header.Set("Accept", "application/vnd.github+json")

resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("list releases for %s/%s: %w; %s", owner, repo, err, pinHint)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("list releases for %s/%s: HTTP %s; %s", owner, repo, resp.Status, pinHint)
}

body, err := pluginstore.ReadCapped(resp.Body, pluginstore.MaxManifestBytes)
if err != nil {
return "", fmt.Errorf("read release list for %s/%s: %w", owner, repo, err)
}
var releases []githubRelease
if err := json.Unmarshal(body, &releases); err != nil {
return "", fmt.Errorf("parse release list for %s/%s: %w", owner, repo, err)
}
return newestReleaseTag(owner, repo, releases)
}

// newestReleaseTag returns the highest semantic-version tag among published
// releases. Drafts and tags that are not semantic versions are ignored.
func newestReleaseTag(owner, repo string, releases []githubRelease) (string, error) {
var newest string
var rejected []string
for _, r := range releases {
if r.Draft {
continue
}
if !semver.IsValid(r.TagName) {
rejected = append(rejected, r.TagName)
continue
}
if newest == "" || semver.Compare(r.TagName, newest) > 0 {
newest = r.TagName
}
}
if newest != "" {
return newest, nil
}
if len(rejected) == 0 {
return "", fmt.Errorf("%s/%s has no published releases", owner, repo)
}
const maxListed = 5
if len(rejected) > maxListed {
rejected = rejected[:maxListed]
}
quoted := make([]string, len(rejected))
for i, tag := range rejected {
quoted[i] = fmt.Sprintf("%q", tag)
}
return "", fmt.Errorf("no release of %s/%s has an installable tag (found %s); expected %s",
owner, repo, strings.Join(quoted, ", "), releaseTagFormat)
}

// fetchChecksums downloads and parses checksums.txt from a GitHub Release.
// Returns a map of filename → sha256hex.
func fetchChecksums(ctx context.Context, owner, repo, tag string) (map[string]string, error) {
Expand Down
149 changes: 149 additions & 0 deletions internal/cmd/plugin/latest_tag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package plugin

import (
"context"
"crypto/tls"
"net/http"
"net/http/httptest"
"strings"
"testing"
)

func TestTagFromLatestReleaseURL(t *testing.T) {
tests := []struct {
name string
url string
want string
wantErr string
}{
{name: "stable tag", url: "https://github.com/o/r/releases/tag/v1.2.3", want: "v1.2.3"},
{name: "pre-release tag", url: "https://github.com/o/r/releases/tag/v1.0.0-preview.28", want: "v1.0.0-preview.28"},
{name: "build metadata", url: "https://github.com/o/r/releases/tag/v1.0.0%2Bbuild.1", want: "v1.0.0+build.1"},
{name: "no stable release redirects to list", url: "https://github.com/o/r/releases", want: ""},
{name: "no stable release with trailing slash", url: "https://github.com/o/r/releases/", want: ""},
{name: "tag without v prefix", url: "https://github.com/o/r/releases/tag/1.2.3", wantErr: `"1.2.3"`},
{name: "non-semver tag", url: "https://github.com/o/r/releases/tag/vNext", wantErr: `"vNext"`},
{name: "unexpected page", url: "https://github.com/o/r/releases/latest", wantErr: "could not parse release tag"},
{name: "traversal in tag", url: "https://github.com/o/r/releases/tag/..%2F..%2Fx", wantErr: "not a semantic version"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tagFromLatestReleaseURL(tt.url)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("tagFromLatestReleaseURL(%q) error = %v, want containing %q", tt.url, err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("tagFromLatestReleaseURL(%q) unexpected error: %v", tt.url, err)
}
if got != tt.want {
t.Fatalf("tagFromLatestReleaseURL(%q) = %q, want %q", tt.url, got, tt.want)
}
})
}
}

func TestNewestReleaseTag(t *testing.T) {
tests := []struct {
name string
releases []githubRelease
want string
wantErr string
}{
{
name: "only pre-releases picks highest",
releases: []githubRelease{
{TagName: "v1.0.0-preview.9", Prerelease: true},
{TagName: "v1.0.0-preview.28", Prerelease: true},
{TagName: "v1.0.0-preview.27", Prerelease: true},
},
want: "v1.0.0-preview.28",
},
{
name: "drafts are skipped",
releases: []githubRelease{
{TagName: "v2.0.0-rc.1", Draft: true},
{TagName: "v1.0.0-rc.1", Prerelease: true},
},
want: "v1.0.0-rc.1",
},
{
name: "invalid tags are skipped",
releases: []githubRelease{
{TagName: "nightly", Prerelease: true},
{TagName: "v0.3.0-beta", Prerelease: true},
},
want: "v0.3.0-beta",
},
{
name: "no releases",
releases: nil,
wantErr: "has no published releases",
},
{
name: "no semver tags",
releases: []githubRelease{
{TagName: "nightly", Prerelease: true},
{TagName: "latest"},
},
wantErr: `"nightly"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := newestReleaseTag("o", "r", tt.releases)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("newestReleaseTag error = %v, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("newestReleaseTag unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("newestReleaseTag = %q, want %q", got, tt.want)
}
})
}
}

func TestFetchNewestReleaseTag_onlyPrereleases(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/repos/o/r/releases" {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{"tag_name":"v1.0.0-preview.28","draft":false,"prerelease":true},
{"tag_name":"v1.0.0-preview.27","draft":false,"prerelease":true}
]`))
}))
t.Cleanup(srv.Close)

origBase := githubAPIBaseURL
githubAPIBaseURL = srv.URL
t.Cleanup(func() { githubAPIBaseURL = origBase })

origTransport := http.DefaultTransport
http.DefaultTransport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // test only
}
t.Cleanup(func() { http.DefaultTransport = origTransport })

got, err := fetchNewestReleaseTag(context.Background(), "o", "r")
if err != nil {
t.Fatalf("fetchNewestReleaseTag: %v", err)
}
if got != "v1.0.0-preview.28" {
t.Fatalf("fetchNewestReleaseTag = %q, want v1.0.0-preview.28", got)
}

if _, err := fetchNewestReleaseTag(context.Background(), "o", "missing"); err == nil ||
!strings.Contains(err.Error(), "o/missing@<tag>") {
t.Fatalf("fetchNewestReleaseTag on 404 error = %v, want hint naming o/missing@<tag>", err)
}
}
Loading