diff --git a/go.mod b/go.mod index a41c8c36cb9..1217615bd0d 100644 --- a/go.mod +++ b/go.mod @@ -314,6 +314,7 @@ require ( github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/go-github v17.0.0+incompatible github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect github.com/imdario/mergo v0.3.12 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect diff --git a/go.sum b/go.sum index 67d69683ed2..d367a3c2f7b 100644 --- a/go.sum +++ b/go.sum @@ -1245,6 +1245,7 @@ github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= github.com/google/go-github/v45 v45.2.0 h1:5oRLszbrkvxDDqBCNj2hjDZMKmvexaZ1xw/FCD+K3FI= github.com/google/go-github/v45 v45.2.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28= diff --git a/pkg/build/cmd/main.go b/pkg/build/cmd/main.go index a045a933076..322e367cd51 100644 --- a/pkg/build/cmd/main.go +++ b/pkg/build/cmd/main.go @@ -232,6 +232,32 @@ func main() { }, }, }, + { + Name: "github", + Usage: "Publish packages to GitHub releases", + Action: PublishGitHub, + Flags: []cli.Flag{ + &dryRunFlag, + &cli.StringFlag{ + Name: "path", + Required: true, + Usage: "Path to the asset to be published", + }, + &cli.StringFlag{ + Name: "repo", + Required: true, + Usage: "GitHub repository", + }, + &cli.StringFlag{ + Name: "tag", + Usage: "Release tag (default from metadata)ß", + }, + &cli.BoolFlag{ + Name: "create", + Usage: "Create release if it doesn't exist", + }, + }, + }, }, }, } diff --git a/pkg/build/cmd/publishgithub.go b/pkg/build/cmd/publishgithub.go new file mode 100644 index 00000000000..02c06b3ab33 --- /dev/null +++ b/pkg/build/cmd/publishgithub.go @@ -0,0 +1,157 @@ +package main + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "strings" + + "github.com/google/go-github/github" + "github.com/urfave/cli/v2" + "golang.org/x/oauth2" +) + +type githubRepositoryService interface { + GetReleaseByTag(ctx context.Context, owner string, repo string, tag string) (*github.RepositoryRelease, *github.Response, error) + CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) + UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) +} + +type githubRepo struct { + owner string + name string +} + +type publishGithubFlags struct { + create bool + dryRun bool + tag string + repo *githubRepo + artifactPath string +} + +var ( + newGithubClient = githubRepositoryClient + errTokenIsEmpty = errors.New("the environment variable GH_TOKEN must be set") + errTagIsEmpty = errors.New(`failed to retrieve release tag from metadata, use "--tag" to set it manually`) + errReleaseNotFound = errors.New(`release not found, use "--create" to create the release`) +) + +func PublishGitHub(ctx *cli.Context) error { + token := os.Getenv("GH_TOKEN") + f, err := getFlags(ctx) + if err != nil { + return err + } + + if f.tag == "" { + return errTagIsEmpty + } + + if token == "" { + return errTokenIsEmpty + } + + if f.dryRun { + return runDryRun(f, token, ctx) + } + + client := newGithubClient(ctx.Context, token) + release, res, err := client.GetReleaseByTag(ctx.Context, f.repo.owner, f.repo.name, f.tag) + if err != nil && res.StatusCode != 404 { + return err + } + + if release == nil { + if f.create { + release, _, err = client.CreateRelease(ctx.Context, f.repo.owner, f.repo.name, &github.RepositoryRelease{TagName: &f.tag}) + if err != nil { + return err + } + } else { + return errReleaseNotFound + } + } + + artifactName := path.Base(f.artifactPath) + file, err := os.Open(f.artifactPath) + if err != nil { + return err + } + + asset, _, err := client.UploadReleaseAsset(ctx.Context, f.repo.owner, f.repo.name, *release.ID, &github.UploadOptions{Name: artifactName}, file) + if err != nil { + return err + } + fmt.Printf("Asset '%s' uploaded to release '%s' on repository '%s/%s'\nDownload: %s\n", *asset.Name, f.tag, f.repo.owner, f.repo.name, *asset.BrowserDownloadURL) + return nil +} + +func githubRepositoryClient(ctx context.Context, token string) githubRepositoryService { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(ctx, ts) + + client := github.NewClient(tc) + return client.Repositories +} + +func getFlags(ctx *cli.Context) (*publishGithubFlags, error) { + metadata, err := GenerateMetadata(ctx) + if err != nil { + return nil, err + } + tag := ctx.Value("tag").(string) + if tag == "" && metadata.GrafanaVersion != "" { + tag = fmt.Sprintf("v%s", metadata.GrafanaVersion) + } + fullRepo := ctx.Value("repo").(string) + dryRun := ctx.Value("dry-run").(bool) + owner := strings.Split(fullRepo, "/")[0] + name := strings.Split(fullRepo, "/")[1] + create := ctx.Value("create").(bool) + artifactPath := ctx.Value("path").(string) + return &publishGithubFlags{ + artifactPath: artifactPath, + create: create, + dryRun: dryRun, + tag: tag, + repo: &githubRepo{ + owner: owner, + name: name, + }, + }, nil +} + +func runDryRun(f *publishGithubFlags, token string, ctx *cli.Context) error { + client := newGithubClient(ctx.Context, token) + fmt.Println("Dry-Run: Retrieving release on repository by tag") + release, res, err := client.GetReleaseByTag(ctx.Context, f.repo.owner, f.repo.name, f.tag) + if err != nil && res.StatusCode != 404 { + fmt.Println("Dry-Run: GitHub communication error:\n", err) + return nil + } + + if release == nil { + if f.create { + fmt.Println("Dry-Run: Release doesn't exist and --create is enabled, so it would try to create the release") + } else { + fmt.Println("Dry-Run: Release doesn't exist and --create is disabled, so it would fail with error") + return nil + } + } + + artifactName := path.Base(f.artifactPath) + fmt.Printf("Dry-Run: Opening file for release: %s\n", f.artifactPath) + _, err = os.Open(f.artifactPath) + if err != nil { + fmt.Println("Dry-Run: Error opening file\n", err) + return nil + } + + fmt.Printf("Dry-Run: Would upload asset '%s' to release '%s' on repo '%s/%s' and return download URL if successful\n", artifactName, f.tag, f.repo.owner, f.repo.name) + return nil +} diff --git a/pkg/build/cmd/publishgithub_test.go b/pkg/build/cmd/publishgithub_test.go new file mode 100644 index 00000000000..845e40a9af9 --- /dev/null +++ b/pkg/build/cmd/publishgithub_test.go @@ -0,0 +1,224 @@ +package main + +import ( + "context" + "errors" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/google/go-github/github" + "github.com/stretchr/testify/assert" + "github.com/urfave/cli/v2" +) + +type githubPublishTestCases struct { + name string + args []string + token string + expectedError error + errorContains string + expectedOutput string + mockedService *mockGitHubRepositoryServiceImpl +} + +var mockGitHubRepositoryService = &mockGitHubRepositoryServiceImpl{} + +func mockGithubRepositoryClient(context.Context, string) githubRepositoryService { + return mockGitHubRepositoryService +} + +func TestPublishGitHub(t *testing.T) { + t.Setenv("DRONE_BUILD_EVENT", "promote") + testApp, testPath := setupPublishGithubTests(t) + mockErrUnauthorized := errors.New("401") + + testCases := []githubPublishTestCases{ + { + name: "try to publish without required flags", + errorContains: `Required flags "path, repo" not set`, + }, + { + name: "try to publish without token", + args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, + expectedError: errTokenIsEmpty, + }, + { + name: "try to publish with invalid token", + token: "invalid", + args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, + mockedService: &mockGitHubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, + expectedError: mockErrUnauthorized, + }, + { + name: "try to publish with valid token and nonexisting tag with create disabled", + token: "valid", + args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, + mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + expectedError: errReleaseNotFound, + }, + { + name: "try to publish with valid token and nonexisting tag with create enabled", + token: "valid", + args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0", "--create"}, + mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + }, + { + name: "try to publish with valid token and existing tag", + token: "valid", + args: []string{"--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, + }, + { + name: "dry run with invalid token", + token: "invalid", + args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, + mockedService: &mockGitHubRepositoryServiceImpl{tagErr: mockErrUnauthorized}, + expectedOutput: "GitHub communication error", + }, + { + name: "dry run with valid token and nonexisting tag with create disabled", + token: "valid", + args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, + mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + expectedOutput: "Release doesn't exist", + }, + { + name: "dry run with valid token and nonexisting tag with create enabled", + token: "valid", + args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0", "--create"}, + mockedService: &mockGitHubRepositoryServiceImpl{tagErr: errReleaseNotFound}, + expectedOutput: "Would upload asset", + }, + { + name: "dry run with valid token and existing tag", + token: "valid", + args: []string{"--dry-run", "--path", testPath, "--repo", "test/test", "--tag", "v1.0.0"}, + expectedOutput: "Would upload asset", + }, + } + + if os.Getenv("DRONE_COMMIT") == "" { + // this test only works locally due to Drone environment + testCases = append(testCases, + githubPublishTestCases{ + name: "try to publish without tag", + args: []string{"--path", testPath, "--repo", "test/test"}, + expectedError: errTagIsEmpty, + }, + ) + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + if test.token != "" { + t.Setenv("GH_TOKEN", test.token) + } + if test.mockedService != nil { + mockGitHubRepositoryService = test.mockedService + } else { + mockGitHubRepositoryService = &mockGitHubRepositoryServiceImpl{} + } + args := []string{"run"} + args = append(args, test.args...) + out, err := captureStdout(t, func() error { + return testApp.Run(args) + }) + if test.expectedOutput != "" { + assert.Contains(t, out, test.expectedOutput) + } + if test.expectedError != nil || test.errorContains != "" { + assert.Error(t, err) + if test.expectedError != nil { + assert.ErrorIs(t, err, test.expectedError) + } + if test.errorContains != "" { + assert.ErrorContains(t, err, test.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func setupPublishGithubTests(t *testing.T) (*cli.App, string) { + t.Helper() + ex, err := os.Executable() + if err != nil { + panic(err) + } + testPath := filepath.Dir(ex) + + newGithubClient = mockGithubRepositoryClient + + testApp := cli.NewApp() + testApp.Action = PublishGitHub + testApp.Flags = []cli.Flag{ + &dryRunFlag, + &cli.StringFlag{ + Name: "path", + Required: true, + Usage: "Path to the asset to be published", + }, + &cli.StringFlag{ + Name: "repo", + Required: true, + Usage: "GitHub repository", + }, + &cli.StringFlag{ + Name: "tag", + Usage: "Release tag (default from metadata)ß", + }, + &cli.BoolFlag{ + Name: "create", + Usage: "Create release if it doesn't exist", + }, + } + return testApp, testPath +} + +func captureStdout(t *testing.T, fn func() error) (string, error) { + t.Helper() + rescueStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + err := fn() + werr := w.Close() + if werr != nil { + return "", err + } + out, _ := io.ReadAll(r) + os.Stdout = rescueStdout + return string(out), err +} + +type mockGitHubRepositoryServiceImpl struct { + tagErr error + createErr error + uploadErr error +} + +func (m *mockGitHubRepositoryServiceImpl) GetReleaseByTag(ctx context.Context, owner string, repo string, tag string) (*github.RepositoryRelease, *github.Response, error) { + var release *github.RepositoryRelease + res := &github.Response{Response: &http.Response{}} + if m.tagErr == nil { + releaseID := int64(1) + release = &github.RepositoryRelease{ID: &releaseID} + } else if errors.Is(m.tagErr, errReleaseNotFound) { + res.StatusCode = 404 + } + return release, res, m.tagErr +} + +func (m *mockGitHubRepositoryServiceImpl) CreateRelease(ctx context.Context, owner string, repo string, release *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) { + releaseID := int64(1) + return &github.RepositoryRelease{ID: &releaseID}, &github.Response{}, m.createErr +} + +func (m *mockGitHubRepositoryServiceImpl) UploadReleaseAsset(ctx context.Context, owner string, repo string, id int64, opt *github.UploadOptions, file *os.File) (*github.ReleaseAsset, *github.Response, error) { + assetName := "test" + assetUrl := "testurl.com.br" + return &github.ReleaseAsset{Name: &assetName, BrowserDownloadURL: &assetUrl}, &github.Response{}, m.uploadErr +}