diff --git a/README.md b/README.md index 3810d591..80eb7570 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ Flags: --skip-existing Skip upload if release exists -t, --token string GitHub Auth Token --make-release-latest bool Mark the created GitHub release as 'latest' (default "true") + --packages-with-index Host the package files in the GitHub Pages branch Global Flags: --config string Config file (default is $HOME/.cr.yaml) @@ -118,6 +119,7 @@ Flags: --release-name-template string Go template for computing release names, using chart metadata (default "{{ .Name }}-{{ .Version }}") --remote string The Git remote used when creating a local worktree for the GitHub Pages branch (default "origin") -t, --token string GitHub Auth Token (only needed for private repos) + --packages-with-index Host the package files in the GitHub Pages branch Global Flags: --config string Config file (default is $HOME/.cr.yaml) diff --git a/cr/cmd/index.go b/cr/cmd/index.go index 61d1bb01..d2dd178d 100644 --- a/cr/cmd/index.go +++ b/cr/cmd/index.go @@ -76,4 +76,5 @@ func init() { flags.Bool("push", false, "Push index.yaml to the GitHub Pages branch (must not be set if --pr is set)") flags.Bool("pr", false, "Create a pull request for index.yaml against the GitHub Pages branch (must not be set if --push is set)") flags.String("release-name-template", "{{ .Name }}-{{ .Version }}", "Go template for computing release names, using chart metadata") + flags.Bool("packages-with-index", false, "Host the package files in the GitHub Pages branch") } diff --git a/cr/cmd/upload.go b/cr/cmd/upload.go index a01c4fd1..18cd6f83 100644 --- a/cr/cmd/upload.go +++ b/cr/cmd/upload.go @@ -57,4 +57,9 @@ func init() { "If it is set to empty string, or the file is not found, the chart description will be used instead. The file is read from the chart package") uploadCmd.Flags().Bool("generate-release-notes", false, "Whether to automatically generate the name and body for this release. See https://docs.github.com/en/rest/releases/releases") uploadCmd.Flags().Bool("make-release-latest", true, "Mark the created GitHub release as 'latest'") + uploadCmd.Flags().String("pages-branch", "gh-pages", "The GitHub pages branch") + uploadCmd.Flags().String("remote", "origin", "The Git remote used when creating a local worktree for the GitHub Pages branch") + uploadCmd.Flags().Bool("push", false, "Push the chart package to the GitHub Pages branch (must not be set if --pr is set)") + uploadCmd.Flags().Bool("pr", false, "Create a pull request for the chart package against the GitHub Pages branch (must not be set if --push is set)") + uploadCmd.Flags().Bool("packages-with-index", false, "Host the package files in the GitHub Pages branch") } diff --git a/doc/cr_index.md b/doc/cr_index.md index 48ea1a0e..68d00d9e 100644 --- a/doc/cr_index.md +++ b/doc/cr_index.md @@ -23,6 +23,7 @@ cr index [flags] -i, --index-path string Path to index file (default ".cr-index/index.yaml") -o, --owner string GitHub username or organization -p, --package-path string Path to directory with chart packages (default ".cr-release-packages") + --packages-with-index Host the package files in the GitHub Pages branch --pages-branch string The GitHub pages branch (default "gh-pages") --pages-index-path string The GitHub pages index path (default "index.yaml") --pr Create a pull request for index.yaml against the GitHub Pages branch (must not be set if --push is set) diff --git a/doc/cr_upload.md b/doc/cr_upload.md index 1ecbdd05..a6bf1ec6 100644 --- a/doc/cr_upload.md +++ b/doc/cr_upload.md @@ -22,8 +22,13 @@ cr upload [flags] --make-release-latest Mark the created GitHub release as 'latest' (default true) -o, --owner string GitHub username or organization -p, --package-path string Path to directory with chart packages (default ".cr-release-packages") + --packages-with-index Host the package files in the GitHub Pages branch + --pages-branch string The GitHub pages branch (default "gh-pages") + --pr Create a pull request for the chart package against the GitHub Pages branch (must not be set if --push is set) + --push Push the chart package to the GitHub Pages branch (must not be set if --pr is set) --release-name-template string Go template for computing release names, using chart metadata (default "{{ .Name }}-{{ .Version }}") --release-notes-file string Markdown file with chart release notes. If it is set to empty string, or the file is not found, the chart description will be used instead. The file is read from the chart package + --remote string The Git remote used when creating a local worktree for the GitHub Pages branch (default "origin") --skip-existing Skip upload if release exists -t, --token string GitHub Auth Token ``` diff --git a/pkg/config/config.go b/pkg/config/config.go index 28bdc375..a0d309ca 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -61,6 +61,7 @@ type Options struct { ReleaseNotesFile string `mapstructure:"release-notes-file"` GenerateReleaseNotes bool `mapstructure:"generate-release-notes"` MakeReleaseLatest bool `mapstructure:"make-release-latest"` + PackagesWithIndex bool `mapstructure:"packages-with-index"` } func LoadConfiguration(cfgFile string, cmd *cobra.Command, requiredFlags []string) (*Options, error) { diff --git a/pkg/git/git.go b/pkg/git/git.go index 79dd12f7..f5260b26 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -60,6 +60,14 @@ func (g *Git) Commit(workingDir string, message string) error { return runCommand(workingDir, command) } +// UpdateBranch runs 'git pull' with the given args. +func (g *Git) Pull(workingDir string, args ...string) error { + pullArgs := []string{"pull"} + pullArgs = append(pullArgs, args...) + command := exec.Command("git", pullArgs...) + return runCommand(workingDir, command) +} + // Push runs 'git push' with the given args. func (g *Git) Push(workingDir string, args ...string) error { pushArgs := []string{"push"} diff --git a/pkg/releaser/releaser.go b/pkg/releaser/releaser.go index d3124fbb..059ec0eb 100644 --- a/pkg/releaser/releaser.go +++ b/pkg/releaser/releaser.go @@ -55,6 +55,7 @@ type GitHub interface { type HTTPClient interface { Get(url string) (*http.Response, error) + GetWithToken(url string, token string) (*http.Response, error) } type Git interface { @@ -63,6 +64,7 @@ type Git interface { Add(workingDir string, args ...string) error Commit(workingDir string, message string) error Push(workingDir string, args ...string) error + Pull(workingDir string, args ...string) error GetPushURL(remote string, token string) (string, error) } @@ -213,36 +215,21 @@ func (r *Releaser) UpdateIndexFile() (bool, error) { if err := copyFile(r.config.IndexPath, indexYamlPath); err != nil { return false, err } - if err := r.git.Add(worktree, indexYamlPath); err != nil { + + if err := r.git.Pull(worktree, r.config.Remote, r.config.PagesBranch); err != nil { return false, err } - if err := r.git.Commit(worktree, fmt.Sprintf("Update %s", r.config.PagesIndexPath)); err != nil { + + if err := r.git.Add(worktree, indexYamlPath); err != nil { return false, err } - pushURL, err := r.git.GetPushURL(r.config.Remote, r.config.Token) - if err != nil { + if err := r.git.Commit(worktree, fmt.Sprintf("Update %s", r.config.PagesIndexPath)); err != nil { return false, err } - if r.config.Push { - fmt.Printf("Pushing to branch %q\n", r.config.PagesBranch) - if err := r.git.Push(worktree, pushURL, "HEAD:refs/heads/"+r.config.PagesBranch); err != nil { - return false, err - } - } else if r.config.PR { - branch := fmt.Sprintf("chart-releaser-%s", randomString(16)) - - fmt.Printf("Pushing to branch %q\n", branch) - if err := r.git.Push(worktree, pushURL, "HEAD:refs/heads/"+branch); err != nil { - return false, err - } - fmt.Printf("Creating pull request against branch %q\n", r.config.PagesBranch) - prURL, err := r.github.CreatePullRequest(r.config.Owner, r.config.GitRepo, "Update index.yaml", branch, r.config.PagesBranch) - if err != nil { - return false, err - } - fmt.Println("Pull request created:", prURL) + if err := r.pushToPagesBranch(worktree); err != nil { + return false, err } return true, nil @@ -302,6 +289,12 @@ func (r *Releaser) addToIndexFile(indexFile *repo.IndexFile, url string) error { s := strings.Split(url, "/") s = s[:len(s)-1] + if r.config.PackagesWithIndex { + // the chart will be stored in the same repo as + // the index file so let's make the path relative + s = s[:0] + } + // Add to index if err := indexFile.MustAdd(c.Metadata, filepath.Base(arch), strings.Join(s, "/"), hash); err != nil { return err @@ -354,6 +347,31 @@ func (r *Releaser) CreateReleases() error { if err := r.github.CreateRelease(context.TODO(), release); err != nil { return errors.Wrapf(err, "error creating GitHub release %s", releaseName) } + + if r.config.PackagesWithIndex { + worktree, err := r.git.AddWorktree("", r.config.Remote+"/"+r.config.PagesBranch) + if err != nil { + return err + } + defer r.git.RemoveWorktree("", worktree) //nolint: errcheck + + pkgTargetPath := filepath.Join(worktree, filepath.Base(p)) + if err := copyFile(p, pkgTargetPath); err != nil { + return err + } + + if err := r.git.Add(worktree, pkgTargetPath); err != nil { + return err + } + + if err := r.git.Commit(worktree, fmt.Sprintf("Publishing chart package for %s", releaseName)); err != nil { + return err + } + + if err := r.pushToPagesBranch(worktree); err != nil { + return err + } + } } return nil @@ -363,6 +381,35 @@ func (r *Releaser) getListOfPackages(dir string) ([]string, error) { return filepath.Glob(filepath.Join(dir, "*.tgz")) } +func (r *Releaser) pushToPagesBranch(worktree string) error { + pushURL, err := r.git.GetPushURL(r.config.Remote, r.config.Token) + if err != nil { + return err + } + + if r.config.Push { + fmt.Printf("Pushing to branch %q\n", r.config.PagesBranch) + if err := r.git.Push(worktree, pushURL, "HEAD:refs/heads/"+r.config.PagesBranch); err != nil { + return err + } + } else if r.config.PR { + branch := fmt.Sprintf("chart-releaser-%s", randomString(16)) + + fmt.Printf("Pushing to branch %q\n", branch) + if err := r.git.Push(worktree, pushURL, "HEAD:refs/heads/"+branch); err != nil { + return err + } + fmt.Printf("Creating pull request against branch %q\n", r.config.PagesBranch) + prURL, err := r.github.CreatePullRequest(r.config.Owner, r.config.GitRepo, "Update index.yaml", branch, r.config.PagesBranch) + if err != nil { + return err + } + fmt.Println("Pull request created:", prURL) + } + + return nil +} + func copyFile(srcFile string, dstFile string) error { source, err := os.Open(srcFile) if err != nil { diff --git a/pkg/releaser/releaser_test.go b/pkg/releaser/releaser_test.go index 739c0aeb..2396891c 100644 --- a/pkg/releaser/releaser_test.go +++ b/pkg/releaser/releaser_test.go @@ -37,6 +37,7 @@ type FakeGitHub struct { type FakeGit struct { indexFile string + mock.Mock } func (f *FakeGit) AddWorktree(workingDir string, committish string) (string, error) { @@ -52,23 +53,37 @@ func (f *FakeGit) AddWorktree(workingDir string, committish string) (string, err } func (f *FakeGit) RemoveWorktree(workingDir string, path string) error { - return nil + f.Called(workingDir, path) + return os.RemoveAll(workingDir) } func (f *FakeGit) Add(workingDir string, args ...string) error { - panic("implement me") + f.Called(workingDir, args) + if len(args) == 0 { + return fmt.Errorf("no args specified") + } + return nil } func (f *FakeGit) Commit(workingDir string, message string) error { - panic("implement me") + f.Called(workingDir, message) + return nil +} + +func (f *FakeGit) Pull(workingDir string, args ...string) error { + f.Called(workingDir, args) + return nil } func (f *FakeGit) Push(workingDir string, args ...string) error { - panic("implement me") + f.Called(workingDir, args) + return nil } func (f *FakeGit) GetPushURL(remote string, token string) (string, error) { - panic("implement me") + f.Called(remote, token) + pushURLWithToken := fmt.Sprintf("https://x-access-token:%s@github.com/owner/repo", token) + return pushURLWithToken, nil } func (f *FakeGitHub) CreateRelease(ctx context.Context, input *github.Release) error { @@ -107,59 +122,60 @@ func TestReleaser_UpdateIndexFile(t *testing.T) { fakeGitHub := new(FakeGitHub) tests := []struct { - name string - exists bool - releaser *Releaser + name string + exists bool + releaser *Releaser + indexFile string }{ { - "index-file-exists", - true, - &Releaser{ + name: "index-file-exists", + exists: true, + releaser: &Releaser{ config: &config.Options{ IndexPath: "testdata/index/index.yaml", PackagePath: "testdata/release-packages", }, github: fakeGitHub, - git: &FakeGit{"testdata/repo/index.yaml"}, }, + indexFile: "testdata/repo/index.yaml", }, { - "index-file-exists-pages-index-path", - true, - &Releaser{ + name: "index-file-exists-pages-index-path", + exists: true, + releaser: &Releaser{ config: &config.Options{ IndexPath: "testdata/index/index.yaml", PackagePath: "testdata/release-packages", PagesIndexPath: "./", }, github: fakeGitHub, - git: &FakeGit{"testdata/repo/index.yaml"}, }, + indexFile: "testdata/repo/index.yaml", }, { - "index-file-does-not-exist", - false, - &Releaser{ + name: "index-file-does-not-exist", + exists: false, + releaser: &Releaser{ config: &config.Options{ IndexPath: filepath.Join(indexDir, "index.yaml"), PackagePath: "testdata/release-packages", }, github: fakeGitHub, - git: &FakeGit{""}, }, + indexFile: "", }, { - "index-file-does-not-exist-pages-index-path", - false, - &Releaser{ + name: "index-file-does-not-exist-pages-index-path", + exists: false, + releaser: &Releaser{ config: &config.Options{ IndexPath: filepath.Join(indexDir, "index.yaml"), PackagePath: "testdata/release-packages", PagesIndexPath: "./", }, github: fakeGitHub, - git: &FakeGit{""}, }, + indexFile: "", }, } for _, tt := range tests { @@ -168,6 +184,11 @@ func TestReleaser_UpdateIndexFile(t *testing.T) { if tt.exists { sha256, _ = provenance.DigestFile(tt.releaser.config.IndexPath) } + + fakeGit := new(FakeGit) + fakeGit.indexFile = tt.indexFile + fakeGit.On("RemoveWorktree", mock.Anything, mock.Anything).Return(nil) + tt.releaser.git = fakeGit update, err := tt.releaser.UpdateIndexFile() assert.NoError(t, err) assert.Equal(t, update, !tt.exists) @@ -189,23 +210,29 @@ func TestReleaser_UpdateIndexFileGenerated(t *testing.T) { fakeGitHub := new(FakeGitHub) tests := []struct { - name string - releaser *Releaser + name string + releaser *Releaser + indexFile string }{ { - "index-file-exists", - &Releaser{ + name: "index-file-exists", + releaser: &Releaser{ config: &config.Options{ IndexPath: filepath.Join(indexDir, "index.yaml"), PackagePath: "testdata/release-packages", }, github: fakeGitHub, - git: &FakeGit{indexFile: "testdata/empty-repo/index.yaml"}, }, + indexFile: "testdata/empty-repo/index.yaml", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + fakeGit := new(FakeGit) + fakeGit.indexFile = tt.indexFile + fakeGit.On("RemoveWorktree", mock.Anything, mock.Anything).Return(nil) + tt.releaser.git = fakeGit + indexFile, _ := repo.LoadIndexFile("testdata/empty-repo/index.yaml") generated := indexFile.Generated update, err := tt.releaser.UpdateIndexFile() @@ -258,37 +285,70 @@ func TestReleaser_splitPackageNameAndVersion(t *testing.T) { func TestReleaser_addToIndexFile(t *testing.T) { tests := []struct { - name string - chart string - version string - error bool + name string + chart string + version string + releaser *Releaser + packagesWithIndex bool + error bool }{ { "invalid-package", "does-not-exist", "0.1.0", + &Releaser{ + config: &config.Options{ + PackagePath: "testdata/release-packages", + PackagesWithIndex: false, + }, + }, + false, true, }, { "valid-package", "test-chart", "0.1.0", + &Releaser{ + config: &config.Options{ + PackagePath: "testdata/release-packages", + PackagesWithIndex: false, + }, + }, + false, + false, + }, + { + "valid-package-with-index", + "test-chart", + "0.1.0", + &Releaser{ + config: &config.Options{ + PackagePath: "testdata/release-packages", + PackagesWithIndex: true, + }, + }, + true, false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - r := &Releaser{ - config: &config.Options{PackagePath: "testdata/release-packages"}, - } indexFile := repo.NewIndexFile() url := fmt.Sprintf("https://myrepo/charts/%s-%s.tgz", tt.chart, tt.version) - err := r.addToIndexFile(indexFile, url) + err := tt.releaser.addToIndexFile(indexFile, url) if tt.error { assert.Error(t, err) assert.False(t, indexFile.Has(tt.chart, tt.version)) } else { assert.True(t, indexFile.Has(tt.chart, tt.version)) + + indexEntry, _ := indexFile.Get(tt.chart, tt.version) + if tt.packagesWithIndex { + assert.Equal(t, filepath.Base(url), indexEntry.URLs[0]) + } else { + assert.Equal(t, url, indexEntry.URLs[0]) + } } }) } @@ -302,50 +362,95 @@ func TestReleaser_CreateReleases(t *testing.T) { version string commit string latest string + Releaser *Releaser error bool }{ { - "invalid-package-path", - "testdata/does-not-exist", - "test-chart", - "0.1.0", - "", - "true", - true, + name: "invalid-package-path", + packagePath: "testdata/does-not-exist", + chart: "test-chart", + version: "0.1.0", + commit: "", + latest: "true", + Releaser: &Releaser{ + config: &config.Options{ + PackagePath: "testdata/does-not-exist", + Commit: "", + PackagesWithIndex: false, + MakeReleaseLatest: true, + }, + }, + error: true, }, { - "valid-package-path", - "testdata/release-packages", - "test-chart", - "0.1.0", - "", - "true", - false, + name: "valid-package-path", + packagePath: "testdata/release-packages", + chart: "test-chart", + version: "0.1.0", + commit: "", + latest: "true", + Releaser: &Releaser{ + config: &config.Options{ + PackagePath: "testdata/release-packages", + Commit: "", + PackagesWithIndex: false, + MakeReleaseLatest: true, + }, + }, + error: false, }, { - "valid-package-path-with-commit", - "testdata/release-packages", - "test-chart", - "0.1.0", - "5e239bd19fbefb9eb0181ecf0c7ef73b8fe2753c", - "true", - false, + name: "valid-package-path-with-commit", + packagePath: "testdata/release-packages", + chart: "test-chart", + version: "0.1.0", + commit: "5e239bd19fbefb9eb0181ecf0c7ef73b8fe2753c", + latest: "true", + Releaser: &Releaser{ + config: &config.Options{ + PackagePath: "testdata/release-packages", + Commit: "5e239bd19fbefb9eb0181ecf0c7ef73b8fe2753c", + PackagesWithIndex: false, + MakeReleaseLatest: true, + }, + }, + error: false, + }, + { + name: "valid-package-with-index", + packagePath: "testdata/release-packages", + chart: "test-chart", + version: "0.1.0", + commit: "5e239bd19fbefb9eb0181ecf0c7ef73b8fe2753c", + latest: "true", + Releaser: &Releaser{ + config: &config.Options{ + PackagePath: "testdata/release-packages", + Commit: "5e239bd19fbefb9eb0181ecf0c7ef73b8fe2753c", + PackagesWithIndex: true, + Push: true, + MakeReleaseLatest: true, + }, + }, + error: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { fakeGitHub := new(FakeGitHub) - r := &Releaser{ - config: &config.Options{ - PackagePath: tt.packagePath, - Commit: tt.commit, - ReleaseNameTemplate: "{{ .Name }}-{{ .Version }}", - MakeReleaseLatest: true, - }, - github: fakeGitHub, - } fakeGitHub.On("CreateRelease", mock.Anything, mock.Anything).Return(nil) - err := r.CreateReleases() + tt.Releaser.github = fakeGitHub + fakeGit := new(FakeGit) + fakeGit.On("AddWorktree", mock.Anything, mock.Anything).Return("/tmp/chart-releaser-012345678", nil) + fakeGit.On("RemoveWorktree", mock.Anything, mock.Anything).Return(nil) + fakeGit.On("Add", mock.Anything, mock.Anything).Return(nil) + fakeGit.On("Commit", mock.Anything, mock.Anything).Return(nil) + fakeGit.On("Push", mock.Anything, mock.Anything).Return(nil) + pushURL := fmt.Sprintf("https://x-access-token:%s@github.com/owner/repo", tt.Releaser.config.Token) + fakeGit.On("GetPushURL", mock.Anything, mock.Anything).Return(pushURL, nil) + tt.Releaser.git = fakeGit + tt.Releaser.config.ReleaseNameTemplate = "{{ .Name }}-{{ .Version }}" + err := tt.Releaser.CreateReleases() if tt.error { assert.Error(t, err) assert.Nil(t, fakeGitHub.release) @@ -353,7 +458,7 @@ func TestReleaser_CreateReleases(t *testing.T) { } else { assert.NoError(t, err) releaseName := fmt.Sprintf("%s-%s", tt.chart, tt.version) - assetPath := fmt.Sprintf("%s/%s-%s.tgz", r.config.PackagePath, tt.chart, tt.version) + assetPath := fmt.Sprintf("%s/%s-%s.tgz", tt.Releaser.config.PackagePath, tt.chart, tt.version) releaseDescription := "A Helm chart for Kubernetes" assert.Equal(t, releaseName, fakeGitHub.release.Name) assert.Equal(t, releaseDescription, fakeGitHub.release.Description) @@ -361,6 +466,7 @@ func TestReleaser_CreateReleases(t *testing.T) { assert.Equal(t, assetPath, fakeGitHub.release.Assets[0].Path) assert.Equal(t, tt.commit, fakeGitHub.release.Commit) assert.Equal(t, tt.latest, fakeGitHub.release.MakeLatest) + assert.Equal(t, tt.Releaser.config.Commit, fakeGitHub.release.Commit) fakeGitHub.AssertNumberOfCalls(t, "CreateRelease", 1) } })