Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/build-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ jobs:
install-mode: goinstall
args: --timeout 10m --verbose --issues-exit-code=0
only-new-issues: true
install-mode: goinstall

code-scan:
name: Code Scan
Expand Down Expand Up @@ -67,7 +68,7 @@ jobs:
permissions:
security-events: write
steps:
# We only need to checkout as govuln does the go setup...
# We only need to checkout as govuln does the go setup using the go.mod file.
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0

Expand Down
4 changes: 4 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ However, by passing the following flag,`-a, --test-all-containers` version-check
`use-metadata.version-checker.io` is not required when this is set. All
other options, apart from URL overrides, are ignored when this is set.

- `use-github-release.version-checker.io/my-container: "true"`: opt-in to sourcing the latest version for `ghcr.io` images from the backing GitHub repository's Releases instead of GHCR package tags.
- **Note:** Release-backed lookups do not include image SHA/digest information, so version-checker cannot detect rebuilt images at the same version. If `use-sha.version-checker.io/my-container: "true"` is also set (or the image tag is `latest` or omitted), version-checker will fall back to using GHCR package tags.
- This is useful for projects that publish version signals in GitHub Releases but do not keep GHCR tags up to date.

- `override-url.version-checker.io/my-container: docker.io/bitnami/etcd`: is
used to change the URL for where to lookup where the latest image version
is. In this example, the current version of `my-container` will be compared
Expand Down
11 changes: 11 additions & 0 deletions known-configurations.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,14 @@ Velero contains an image `1220` which is always the latest.
| Annotation |
|-|
| `match-regex.version-checker.io/velero: 'v(\d+)\.(\d+)\.(\d+)'` |

### n8n: ghcr.io/n8n-io/n8n

n8n publishes the versions to compare against via GitHub Releases rather than
using GHCR package tags with enough specificity.

| Annotation |
|-|
| `use-github-release.version-checker.io/n8n: "true"` |

> Note: This annotation only affects semver/latest checks. If `use-sha.version-checker.io/n8n: "true"` is set or the tag is omitted/`latest`, version-checker will fall back to GHCR package tags, as GitHub Releases do not provide SHA information.
4 changes: 4 additions & 0 deletions pkg/api/annotations.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ const (
// set. All other options are ignored when this is set.
MatchRegexAnnotationKey = "match-regex.version-checker.io"

// UseGitHubReleaseAnnotationKey will use GitHub releases as the source for
// latest version checks against GHCR-backed images.
UseGitHubReleaseAnnotationKey = "use-github-release.version-checker.io"

// UseMetaDataAnnotationKey is defined as a tag containing anything after the
// patch digit.
// e.g. v1.0.1-gke.3 v1.0.1-alpha.0, v1.2.3.4...
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ type Options struct {
UseSHA bool `json:"use-sha,omitempty"`
// Resolve SHA to a TAG
ResolveSHAToTags bool `json:"resolve-sha-to-tags,omitempty"`
// Use GitHub releases as the source for latest GHCR versions.
UseGitHubRelease bool `json:"use-github-release,omitempty"`

// UseMetaData defines whether tags with '-alpha', '-debian.0' etc. is
// permissible.
Expand Down
30 changes: 27 additions & 3 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ import (

// Used for testing/mocking purposes
type ClientHandler interface {
Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error)
Tags(ctx context.Context, imageURL string, opts *api.Options) ([]api.ImageTag, error)
}

// Client is a container image registry client to list tags of given image
// URLs.
type Client struct {
fallbackClient api.ImageClient
ghcrClient *ghcr.Client
ghcrHostname string

log *logrus.Entry
clients []api.ImageClient
Expand Down Expand Up @@ -99,16 +101,19 @@ func New(ctx context.Context, log *logrus.Entry, opts Options) (*Client, error)
if err != nil {
return nil, fmt.Errorf("failed to create fallback client: %w", err)
}
ghcrClient := ghcr.New(opts.GHCR)

c := &Client{
ghcrClient: ghcrClient,
ghcrHostname: opts.GHCR.Hostname,
// Append all the clients in order of which we want to check against
clients: append(
selfhostedClients,
acrClient,
ecr.New(opts.ECR),
dockerClient,
gcr.New(opts.GCR),
ghcr.New(opts.GHCR),
ghcrClient,
quay.New(opts.Quay, log),
),
fallbackClient: fallbackClient,
Expand All @@ -123,15 +128,34 @@ func New(ctx context.Context, log *logrus.Entry, opts Options) (*Client, error)
}

// Tags returns the full list of image tags available, for a given image URL.
func (c *Client) Tags(ctx context.Context, imageURL string) ([]api.ImageTag, error) {
func (c *Client) Tags(ctx context.Context, imageURL string, opts *api.Options) ([]api.ImageTag, error) {
client, host, path := c.fromImageURL(imageURL)

c.log.Debugf("using client %q for image URL %q", client.Name(), imageURL)
repo, image := client.RepoImageFromPath(path)

if opts != nil && opts.UseGitHubRelease && !opts.UseSHA {
if ghcrClient, ok := client.(*ghcr.Client); ok {
return ghcrClient.ReleaseTags(ctx, repo, image)
}

if c.ghcrClient != nil && isGHCRHost(host, c.ghcrHostname) {
repo, image := c.ghcrClient.RepoImageFromPath(path)
return c.ghcrClient.ReleaseTags(ctx, repo, image)
}
}
Comment thread
joshw123 marked this conversation as resolved.

return client.Tags(ctx, host, repo, image)
Comment thread
davidcollom marked this conversation as resolved.
}

func isGHCRHost(host, configuredHostname string) bool {
if configuredHostname != "" && configuredHostname == host {
return true
}

return ghcr.HostReg.MatchString(host)
}

// fromImageURL will return the appropriate registry client for a given
// image URL, and the host + path to search.
func (c *Client) fromImageURL(imageURL string) (api.ImageClient, string, string) {
Expand Down
59 changes: 59 additions & 0 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ package client

import (
"context"
"net/http"
"testing"
"time"

"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"

"github.com/jarcoal/httpmock"
"github.com/jetstack/version-checker/pkg/api"
"github.com/jetstack/version-checker/pkg/client/acr"
"github.com/jetstack/version-checker/pkg/client/docker"
Expand Down Expand Up @@ -188,3 +191,59 @@ func TestFromImageURL(t *testing.T) {
})
}
}

func TestTagsUsesGitHubReleasesForGHCR(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()

httpmock.RegisterResponder("GET", "https://api.github.com/repos/test-user-owner/test-repo/releases",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewStringResponse(200, `[
{
"tag_name": "v1.2.3",
"published_at": "2023-07-08T12:34:56Z"
}
]`), nil
})

handler, err := New(context.TODO(), logrus.NewEntry(logrus.New()), Options{
GHCR: ghcr.Options{
Token: "test-token",
},
})
assert.NoError(t, err)

tags, err := handler.Tags(context.Background(), "ghcr.io/test-user-owner/test-repo", &api.Options{
UseGitHubRelease: true,
})
assert.NoError(t, err)
assert.Equal(t, []api.ImageTag{
{Tag: "v1.2.3", Timestamp: time.Date(2023, time.July, 8, 12, 34, 56, 0, time.UTC)},
}, tags)
}

func TestTagsUsesGitHubReleasesForGHCRWithoutToken(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()

httpmock.RegisterResponder("GET", "https://api.github.com/repos/test-user-owner/test-repo/releases",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewStringResponse(200, `[
{
"tag_name": "v2.3.4",
"published_at": "2024-01-01T10:20:30Z"
}
]`), nil
})

handler, err := New(context.TODO(), logrus.NewEntry(logrus.New()), Options{})
assert.NoError(t, err)

tags, err := handler.Tags(context.Background(), "ghcr.io/test-user-owner/test-repo", &api.Options{
UseGitHubRelease: true,
})
assert.NoError(t, err)
assert.Equal(t, []api.ImageTag{
{Tag: "v2.3.4", Timestamp: time.Date(2024, time.January, 1, 10, 20, 30, 0, time.UTC)},
}, tags)
}
59 changes: 59 additions & 0 deletions pkg/client/ghcr/ghcr.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http"
"net/url"
"strings"
"time"

"github.com/jetstack/version-checker/pkg/api"

Expand Down Expand Up @@ -85,6 +86,32 @@ func (c *Client) Tags(ctx context.Context, _, owner, repo string) ([]api.ImageTa
return tags, nil
}

func (c *Client) ReleaseTags(ctx context.Context, owner, pkg string) ([]api.ImageTag, error) {
repo := releaseRepoFromPackage(pkg)
if repo == "" {
return nil, fmt.Errorf("unable to determine GitHub repository from package %q", pkg)
}

opts := &github.ListOptions{PerPage: 100}
var tags []api.ImageTag
for {
releases, resp, err := c.client.Repositories.ListReleases(ctx, owner, repo, opts)
if err != nil {
return nil, fmt.Errorf("getting releases: %w", err)
}

tags = append(tags, extractReleaseTags(releases)...)

if resp.NextPage == 0 {
break
}

opts.Page = resp.NextPage
}

return tags, nil
}

func (c *Client) determineGetAllVersionsFunc(ctx context.Context, owner, repo string) (func(ctx context.Context, owner, pkgType, repo string, opts *github.PackageListOptions) ([]*github.PackageVersion, *github.Response, error), string, error) {
getAllVersions := c.client.Organizations.PackageGetAllVersions
ownerType, err := c.ownerType(ctx, owner)
Expand Down Expand Up @@ -162,3 +189,35 @@ func (c *Client) ownerType(ctx context.Context, owner string) (string, error) {

return ownerType, nil
}

func releaseRepoFromPackage(pkg string) string {
repo, _, _ := strings.Cut(pkg, "/")
return repo
}

func extractReleaseTags(releases []*github.RepositoryRelease) []api.ImageTag {
tags := make([]api.ImageTag, 0, len(releases))
for _, release := range releases {
if release.GetDraft() || release.GetTagName() == "" {
continue
}

tags = append(tags, api.ImageTag{
Tag: release.GetTagName(),
Timestamp: releaseTimestamp(release),
})
}

return tags
}

func releaseTimestamp(release *github.RepositoryRelease) time.Time {
switch {
case release.PublishedAt != nil:
return release.PublishedAt.Time
case release.CreatedAt != nil:
return release.CreatedAt.Time
default:
return time.Time{}
}
}
43 changes: 43 additions & 0 deletions pkg/client/ghcr/ghcr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import (
"context"
"net/http"
"testing"
"time"

"github.com/google/go-github/v70/github"
"github.com/jarcoal/httpmock"
"github.com/jetstack/version-checker/pkg/api"
"github.com/stretchr/testify/assert"
)

Expand Down Expand Up @@ -60,6 +62,27 @@ func registerTagResponders() {
})
}

func registerReleaseResponders() {
httpmock.RegisterResponder("GET", "https://api.github.com/repos/test-user-owner/test-repo/releases",
func(req *http.Request) (*http.Response, error) {
return httpmock.NewStringResponse(200, `[
{
"tag_name": "v1.0.0",
"published_at": "2023-07-08T12:34:56Z"
},
{
"tag_name": "v1.1.0",
"created_at": "2023-08-08T12:34:56Z"
},
{
"tag_name": "v9.9.9",
"draft": true,
"published_at": "2023-09-08T12:34:56Z"
}
]`), nil
})
}

func TestClient_Tags(t *testing.T) {
setup()
defer teardown()
Expand Down Expand Up @@ -162,3 +185,23 @@ func TestClient_Tags(t *testing.T) {
assert.ElementsMatch(t, []string{"tag1", "tag2"}, []string{tags[0].Tag, tags[1].Tag})
})
}

func TestClient_ReleaseTags(t *testing.T) {
setup()
defer teardown()

ctx := context.Background()

httpmock.Reset()
registerReleaseResponders()

client := New(Options{})
client.client = github.NewClient(nil)

tags, err := client.ReleaseTags(ctx, "test-user-owner", "test-repo/subpath")
assert.NoError(t, err)
assert.Equal(t, []api.ImageTag{
{Tag: "v1.0.0", Timestamp: time.Date(2023, time.July, 8, 12, 34, 56, 0, time.UTC)},
{Tag: "v1.1.0", Timestamp: time.Date(2023, time.August, 8, 12, 34, 56, 0, time.UTC)},
}, tags)
}
13 changes: 13 additions & 0 deletions pkg/controller/checker/checker.go
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,23 @@ func (c *Checker) isLatestSemver(ctx context.Context, imageURL, currentSHA strin
isLatest = true
}

// GitHub release tags can be semver-only and may not include digest data.
// Only apply SHA mismatch logic when the latest image has comparable SHA(s).
hasComparableSHA := latestImage.SHA != ""
if !hasComparableSHA {
for _, child := range latestImage.Children {
if child.SHA != "" {
hasComparableSHA = true
break
}
}
}

// If using the same image version,
// but the SHA has been updated upstream,
// mark not latest
if currentImage.Equal(latestImageV) &&
hasComparableSHA &&
!latestImage.MatchesSHA(currentSHA) {
isLatest = false
if latestImage.SHA != "" {
Expand Down
12 changes: 12 additions & 0 deletions pkg/controller/checker/checker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,18 @@ func TestIsLatestSemver(t *testing.T) {
},
expIsLatest: true,
},
"if current semver is equal, and latest has no SHA data, then true": {
imageURL: "ghcr.io/test/repo",
currentSHA: "sha256:current",
currentImage: semver.Parse("v1.2.4"),
searchResp: &api.ImageTag{
Tag: "v1.2.4",
},
expLatestImage: &api.ImageTag{
Tag: "v1.2.4",
},
expIsLatest: true,
},
"if current semver is more, then true": {
imageURL: "docker.io",
currentSHA: "123",
Expand Down
Loading