diff --git a/pkg/cli/release.go b/pkg/cli/release.go index 2c48b73..7b01074 100644 --- a/pkg/cli/release.go +++ b/pkg/cli/release.go @@ -1,7 +1,9 @@ package cli import ( + "fmt" "os" + "path/filepath" "github.com/jimeh/build-emacs-for-macos/pkg/plan" "github.com/jimeh/build-emacs-for-macos/pkg/release" @@ -57,6 +59,7 @@ func releaseCmd() *cli2.Command { }, Subcommands: []*cli2.Command{ releaseCheckCmd(), + releasePublishCmd(), }, } } @@ -122,3 +125,84 @@ func releaseCheckAction( return release.Check(c.Context, rlsOpts) } + +func releasePublishCmd() *cli2.Command { + return &cli2.Command{ + Name: "publish", + Usage: "publish a GitHub release with specified asset " + + "files", + ArgsUsage: "[ ...]", + Flags: []cli2.Flag{ + &cli2.StringFlag{ + Name: "sha", + Aliases: []string{"s"}, + Usage: "git SHA to create release on", + EnvVars: []string{"GITHUB_SHA"}, + }, + &cli2.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "release type, must be normal, prerelease, or draft", + Value: "normal", + }, + &cli2.StringFlag{ + Name: "title", + Usage: "release title, will use release name if not " + + "specified", + Value: "", + }, + }, + Action: releaseActionWrapper(releasePublishAction), + } +} + +func releasePublishAction( + c *cli2.Context, + opts *Options, + rOpts *releaseOptions, +) error { + rlsOpts := &release.PublishOptions{ + Repository: rOpts.Repository, + CommitRef: c.String("release-sha"), + ReleaseName: rOpts.Name, + ReleaseTitle: c.String("title"), + AssetFiles: c.Args().Slice(), + GithubToken: rOpts.GithubToken, + } + + rlsType := c.String("type") + switch rlsType { + case "draft": + rlsOpts.ReleaseType = release.Draft + case "prerelease": + rlsOpts.ReleaseType = release.Prerelease + case "normal": + rlsOpts.ReleaseType = release.Normal + default: + return fmt.Errorf("invalid --type \"%s\"", rlsType) + } + + if rOpts.Plan != nil { + if rOpts.Plan.Release != nil { + rlsOpts.ReleaseName = rOpts.Plan.Release.Name + rlsOpts.ReleaseTitle = rOpts.Plan.Release.Title + + if rOpts.Plan.Release.Draft { + rlsOpts.ReleaseType = release.Draft + } else if rOpts.Plan.Release.Prerelease { + rlsOpts.ReleaseType = release.Prerelease + } + } + + if rOpts.Plan.Output != nil { + rlsOpts.AssetFiles = []string{ + filepath.Join( + rOpts.Plan.Output.Directory, + rOpts.Plan.Output.DiskImage, + ), + } + } + } + + return release.Publish(c.Context, rlsOpts) +} diff --git a/pkg/release/publish.go b/pkg/release/publish.go new file mode 100644 index 0000000..d96f675 --- /dev/null +++ b/pkg/release/publish.go @@ -0,0 +1,242 @@ +package release + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/google/go-github/v35/github" + "github.com/hashicorp/go-hclog" + "github.com/jimeh/build-emacs-for-macos/pkg/gh" + "github.com/jimeh/build-emacs-for-macos/pkg/repository" +) + +type releaseType int + +// Release type constants +const ( + Normal releaseType = iota + Draft + Prerelease +) + +type PublishOptions struct { + // Repository is the GitHub repository to publish the release on. + Repository *repository.Repository + + // CommitRef is the git commit ref to create the release tag on. + CommitRef string + + // ReleaseName is the name of the git tag to create the release on. + ReleaseName string + + // ReleaseTitle is the title of the release, if not specified ReleaseName + // will be used. + ReleaseTitle string + + // ReleaseType is the type of release to create (normal, prerelease, or + // draft) + ReleaseType releaseType + + // AssetFiles is a list of files which must all exist in the release for + // the check to pass. + AssetFiles []string + + // GitHubToken is the OAuth token used to talk to the GitHub API. + GithubToken string +} + +//nolint:funlen,gocyclo +// Publish creates and publishes a GitHub release. +func Publish(ctx context.Context, opts *PublishOptions) error { + logger := hclog.FromContext(ctx).Named("release") + gh := gh.New(ctx, opts.GithubToken) + + files, err := publishFileList(opts.AssetFiles) + if err != nil { + return err + } + + tagName := opts.ReleaseName + name := opts.ReleaseTitle + if name == "" { + name = tagName + } + + prerelease := opts.ReleaseType == Prerelease + draft := opts.ReleaseType == Draft + + logger.Info("checking release", "tag", tagName) + release, resp, err := gh.Repositories.GetReleaseByTag( + ctx, opts.Repository.Owner(), opts.Repository.Name(), tagName, + ) + if err != nil { + if resp.StatusCode != http.StatusNotFound { + return err + } + + logger.Info("creating release", "tag", tagName, "name", name) + + release, _, err = gh.Repositories.CreateRelease( + ctx, opts.Repository.Owner(), opts.Repository.Name(), + &github.RepositoryRelease{ + Name: &name, + TagName: &tagName, + TargetCommitish: &opts.CommitRef, + Prerelease: boolPtr(false), + Draft: boolPtr(true), + }, + ) + if err != nil { + return err + } + } + + for _, fileName := range files { + fileIO, err2 := os.Open(fileName) + if err2 != nil { + return err2 + } + defer fileIO.Close() + + fileInfo, err2 := fileIO.Stat() + if err2 != nil { + return err2 + } + + fileBaseName := filepath.Base(fileName) + assetExists := false + + for _, a := range release.Assets { + if a.GetName() != fileBaseName { + continue + } + + if a.GetSize() == int(fileInfo.Size()) { + logger.Info("asset exists with correct size", + "file", fileBaseName, + "local_size", byteCountIEC(fileInfo.Size()), + "remote_size", byteCountIEC(int64(a.GetSize())), + ) + assetExists = true + } else { + _, err = gh.Repositories.DeleteReleaseAsset( + ctx, opts.Repository.Owner(), opts.Repository.Name(), + a.GetID(), + ) + if err != nil { + return err + } + logger.Info( + "deleted asset with wrong size", "file", fileBaseName, + ) + } + } + + if !assetExists { + logger.Info("uploading asset", + "file", fileBaseName, + "size", byteCountIEC(fileInfo.Size()), + ) + _, _, err2 = gh.Repositories.UploadReleaseAsset( + ctx, opts.Repository.Owner(), opts.Repository.Name(), + release.GetID(), + &github.UploadOptions{Name: fileBaseName}, + fileIO, + ) + if err2 != nil { + return err2 + } + } + } + + changed := false + if release.GetName() != name { + release.Name = &name + changed = true + } + + if release.GetDraft() != draft { + release.Draft = &draft + changed = true + } + + if !draft && release.GetPrerelease() != prerelease { + release.Prerelease = &prerelease + changed = true + } + + if changed { + release, _, err = gh.Repositories.EditRelease( + ctx, opts.Repository.Owner(), opts.Repository.Name(), + release.GetID(), release, + ) + if err != nil { + return err + } + } + + logger.Info("release created", "url", release.GetHTMLURL()) + + return nil +} + +func publishFileList(files []string) ([]string, error) { + var output []string + for _, file := range files { + var err error + file, err = filepath.Abs(file) + if err != nil { + return nil, err + } + + stat, err := os.Stat(file) + if err != nil { + return nil, err + } + if !stat.Mode().IsRegular() { + return nil, fmt.Errorf("\"%s\" is not a file", file) + } + + output = append(output, file) + sumFile := file + ".sha256" + + _, err = os.Stat(sumFile) + fmt.Printf("err: %+v\n", err) + if err != nil { + if os.IsNotExist(err) { + continue + } + + return nil, err + } + output = append(output, sumFile) + } + + return output, nil +} + +func byteCountIEC(b int64) string { + const unit = 1024 + if b < unit { + if b == 1 { + return fmt.Sprintf("%d byte", b) + } + + return fmt.Sprintf("%d bytes", b) + } + div, exp := int64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + + return fmt.Sprintf("%.1f %ciB", + float64(b)/float64(div), "KMGTPE"[exp]) +} + +func boolPtr(v bool) *bool { + return &v +}