mirror of https://github.com/grafana/grafana
Build: Add github release command to build/cmd (#56349)
* Add github release command to build/cmd * Use go-github library and implement dry-run * Make tag optional and default to metadata * Fix minor bug with tag default * Make some refactors to ease testing * Add tests for publish github command * Refactor publish github tests * Refactor test helper function name * Isolate local testpull/56867/head
parent
4b68918b0b
commit
96a97f9827
@ -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 |
||||||
|
} |
||||||
@ -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 |
||||||
|
} |
||||||
Loading…
Reference in new issue