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
5 changes: 4 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,9 +68,11 @@ 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@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
with:
persist-credentials: false

- id: govulncheck
uses: golang/govulncheck-action@v1
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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,9 @@ require (
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect
github.com/mailru/easyjson v0.9.2 // indirect
Expand Down
6 changes: 0 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -221,17 +221,12 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
Expand Down Expand Up @@ -314,7 +309,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
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)
}
Loading