diff --git a/cli/catalogue/catalogue.go b/cli/catalogue/catalogue.go index 3205c6ce8..43de4d8b0 100644 --- a/cli/catalogue/catalogue.go +++ b/cli/catalogue/catalogue.go @@ -235,7 +235,8 @@ A new catalogue copy can be published to the recipes repository by passing the } } - if err := gitPkg.Commit("**.json", internal.CommitMessage, internal.Dry, internal.Push); err != nil { + cataloguePath := path.Join(config.ABRA_DIR, "catalogue") + if err := gitPkg.Commit(cataloguePath, "**.json", internal.CommitMessage, internal.Dry, internal.Push); err != nil { logrus.Fatal(err) } } diff --git a/cli/recipe/release.go b/cli/recipe/release.go index 4a5c43c68..d9bdcfab0 100644 --- a/cli/recipe/release.go +++ b/cli/recipe/release.go @@ -17,7 +17,6 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/docker/distribution/reference" "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) @@ -66,234 +65,70 @@ You may invoke this command in "wizard" mode and be prompted for input: BashComplete: autocomplete.RecipeNameComplete, Action: func(c *cli.Context) error { recipe := internal.ValidateRecipeWithPrompt(c) - directory := path.Join(config.APPS_DIR, recipe.Name) - tagString := c.Args().Get(1) - mainApp := internal.GetMainApp(recipe) imagesTmp, err := getImageVersions(recipe) if err != nil { logrus.Fatal(err) } + + mainApp := internal.GetMainApp(recipe) mainAppVersion := imagesTmp[mainApp] - - if err := recipePkg.EnsureExists(recipe.Name); err != nil { - logrus.Fatal(err) - } - if mainAppVersion == "" { logrus.Fatalf("main app service version for %s is empty?", recipe.Name) } + tagString := c.Args().Get(1) if tagString != "" { if _, err := tagcmp.Parse(tagString); err != nil { - logrus.Fatal("invalid tag specified") + logrus.Fatalf("cannot parse %s, invalid tag specified?", tagString) } } - if (!internal.Major && !internal.Minor && !internal.Patch) && tagString == "" { - logrus.Fatal("please specify or bump type (--major/--minor/--patch)") - } - if (internal.Major || internal.Minor || internal.Patch) && tagString != "" { logrus.Fatal("cannot specify tag and bump type at the same time") } - // bumpType is used to decide what part of the tag should be incremented - bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch) - if bumpType != 0 { - // a bitwise check if the number is a power of 2 - if (bumpType & (bumpType - 1)) != 0 { - logrus.Fatal("you can only use one of: --major, --minor, --patch.") - } - } - - if err := internal.PromptBumpType(tagString); err != nil { - logrus.Fatal(err) - } - - if internal.TagMessage == "" && !internal.NoInput { - prompt := &survey.Input{ - Message: "tag message", - Default: "chore: publish new release", - } - - if err := survey.AskOne(prompt, &internal.TagMessage); err != nil { - logrus.Fatal(err) - } - } - - var createTagOptions git.CreateTagOptions - createTagOptions.Message = internal.TagMessage - - if !internal.Commit && !internal.NoInput { - prompt := &survey.Confirm{ - Message: "git commit changes also?", - } - - if err := survey.AskOne(prompt, &internal.Commit); err != nil { - return err - } - } - - if !internal.Push && !internal.NoInput { - prompt := &survey.Confirm{ - Message: "git push changes also?", - } - - if err := survey.AskOne(prompt, &internal.Push); err != nil { - return err - } - } - - if internal.Commit || internal.CommitMessage != "" { - if internal.CommitMessage == "" && !internal.NoInput { - prompt := &survey.Input{ - Message: "commit message", - Default: "chore: publish new %s version", - } - if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil { - logrus.Fatal(err) - } - } - - if internal.CommitMessage == "" { - logrus.Fatal("no commit message specified?") - } - - if err := gitPkg.Commit("compose.**yml", internal.CommitMessage, internal.Dry, false); err != nil { - logrus.Fatal(err) - } - } - - repo, err := git.PlainOpen(directory) - if err != nil { - logrus.Fatal(err) - } - - head, err := repo.Head() - if err != nil { - logrus.Fatal(err) - } - if tagString != "" { - tag, err := tagcmp.Parse(tagString) - if err != nil { + if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil { logrus.Fatal(err) } - - if tag.MissingMinor { - tag.Minor = "0" - tag.MissingMinor = false - } - - if tag.MissingPatch { - tag.Patch = "0" - tag.MissingPatch = false - } - - tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion) - if internal.Dry { - hash := abraFormatter.SmallSHA(head.Hash().String()) - logrus.Info(fmt.Sprintf("dry run: not creating tag %s at %s", tagString, hash)) - return nil - } - - repo.CreateTag(tagString, head.Hash(), &createTagOptions) - hash := abraFormatter.SmallSHA(head.Hash().String()) - logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash)) - if internal.Push && !internal.Dry { - if err := repo.Push(&git.PushOptions{}); err != nil { - logrus.Fatal(err) - } - logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString)) - } else { - logrus.Info("dry run: no changes pushed") - } - - return nil } - // get the latest tag with its hash, name etc - var lastGitTag tagcmp.Tag - iter, err := repo.Tags() + tags, err := recipe.Tags() if err != nil { - logrus.Fatal(err) + return err } - if err := iter.ForEach(func(ref *plumbing.Reference) error { - obj, err := repo.TagObject(ref.Hash()) - if err != nil { - return err - } - - tagcmpTag, err := tagcmp.Parse(obj.Name) - if err != nil { - return err - } - - if (lastGitTag == tagcmp.Tag{}) { - lastGitTag = tagcmpTag - } else if tagcmpTag.IsGreaterThan(lastGitTag) { - lastGitTag = tagcmpTag - } - - return nil - }); err != nil { - logrus.Fatal(err) - } - - if lastGitTag.String() == "" || lastGitTag.String() == ".." { - logrus.Warn("no previous git tags found, this is the initial release?") - } - - newTag := lastGitTag - var newtagString string - if bumpType > 0 { - if internal.Patch { - now, err := strconv.Atoi(newTag.Patch) - if err != nil { - logrus.Fatal(err) - } - - newTag.Patch = strconv.Itoa(now + 1) - } else if internal.Minor { - now, err := strconv.Atoi(newTag.Minor) - if err != nil { - logrus.Fatal(err) - } - - newTag.Patch = "0" - newTag.Minor = strconv.Itoa(now + 1) - } else if internal.Major { - now, err := strconv.Atoi(newTag.Major) - if err != nil { - logrus.Fatal(err) - } - - newTag.Patch = "0" - newTag.Minor = "0" - newTag.Major = strconv.Itoa(now + 1) - } - } - - newTag.Metadata = mainAppVersion - newtagString = newTag.String() - if internal.Dry { - hash := abraFormatter.SmallSHA(head.Hash().String()) - logrus.Info(fmt.Sprintf("dry run: not creating tag %s at %s", newtagString, hash)) - return nil - } - - repo.CreateTag(newtagString, head.Hash(), &createTagOptions) - hash := abraFormatter.SmallSHA(head.Hash().String()) - logrus.Info(fmt.Sprintf("created tag %s at %s", newtagString, hash)) - if internal.Push && !internal.Dry { - if err := repo.Push(&git.PushOptions{}); err != nil { + if len(tags) > 0 { + if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil { logrus.Fatal(err) } - logrus.Info(fmt.Sprintf("pushed tag %s to remote", newtagString)) } else { - logrus.Info("dry run: no changes pushed") + logrus.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name) + + initTag, err := recipePkg.GetVersionLabelLocal(recipe) + if err != nil { + logrus.Fatal(err) + } + + logrus.Warnf("discovered %s as currently synced recipe label", initTag) + + prompt := &survey.Confirm{ + Message: fmt.Sprintf("use %s as the initial release?", initTag), + } + + var response bool + if err := survey.AskOne(prompt, &response); err != nil { + return err + } + + if !response { + logrus.Fatalf("please fix your synced label for %s and re-run this command", recipe.Name) + } + + if err := createReleaseFromTag(recipe, initTag, mainAppVersion); err != nil { + logrus.Fatal(err) + } } return nil @@ -333,6 +168,48 @@ func getImageVersions(recipe recipe.Recipe) (map[string]string, error) { return services, nil } +// createReleaseFromTag creates a new release based on a supplied recipe version string +func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error { + var err error + + directory := path.Join(config.APPS_DIR, recipe.Name) + repo, err := git.PlainOpen(directory) + if err != nil { + return err + } + + tag, err := tagcmp.Parse(tagString) + if err != nil { + return err + } + + if tag.MissingMinor { + tag.Minor = "0" + tag.MissingMinor = false + } + + if tag.MissingPatch { + tag.Patch = "0" + tag.MissingPatch = false + } + + if err := commitRelease(recipe); err != nil { + logrus.Fatal(err) + } + + tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion) + + if err := tagRelease(tagString, repo); err != nil { + logrus.Fatal(err) + } + + if err := pushRelease(tagString, repo); err != nil { + logrus.Fatal(err) + } + + return nil +} + // btoi converts a boolean value into an integer func btoi(b bool) int { if b { @@ -341,3 +218,187 @@ func btoi(b bool) int { return 0 } + +// getTagCreateOptions constructs git tag create options +func getTagCreateOptions() (git.CreateTagOptions, error) { + if internal.TagMessage == "" && !internal.NoInput { + prompt := &survey.Input{ + Message: "git tag message", + Default: "chore: publish new release", + } + + if err := survey.AskOne(prompt, &internal.TagMessage); err != nil { + return git.CreateTagOptions{}, err + } + } + + return git.CreateTagOptions{Message: internal.TagMessage}, nil +} + +func commitRelease(recipe recipe.Recipe) error { + if internal.Dry { + logrus.Info("dry run: no changed committed") + return nil + } + + if !internal.Commit && !internal.NoInput { + prompt := &survey.Confirm{ + Message: "git commit changes?", + } + + if err := survey.AskOne(prompt, &internal.Commit); err != nil { + return err + } + } + + if internal.CommitMessage == "" && !internal.NoInput { + prompt := &survey.Input{ + Message: "commit message", + Default: "chore: publish new %s version", + } + if err := survey.AskOne(prompt, &internal.CommitMessage); err != nil { + return err + } + } + + if internal.Commit { + repoPath := path.Join(config.APPS_DIR, recipe.Name) + if err := gitPkg.Commit(repoPath, "compose.**yml", internal.CommitMessage, internal.Dry, false); err != nil { + return err + } + } + + return nil +} + +func tagRelease(tagString string, repo *git.Repository) error { + if internal.Dry { + logrus.Info("dry run: no git tag created") + return nil + } + + head, err := repo.Head() + if err != nil { + return err + } + + createTagOptions, err := getTagCreateOptions() + if err != nil { + return err + } + + _, err = repo.CreateTag(tagString, head.Hash(), &createTagOptions) + if err != nil { + return err + } + + hash := abraFormatter.SmallSHA(head.Hash().String()) + logrus.Info(fmt.Sprintf("created tag %s at %s", tagString, hash)) + + return nil +} + +func pushRelease(tagString string, repo *git.Repository) error { + if internal.Dry { + logrus.Info("dry run: no changes pushed") + return nil + } + + if !internal.Push && !internal.NoInput { + prompt := &survey.Confirm{ + Message: "git push changes?", + } + + if err := survey.AskOne(prompt, &internal.Push); err != nil { + return err + } + } + + if internal.Push { + if err := repo.Push(&git.PushOptions{}); err != nil { + return err + } + logrus.Info(fmt.Sprintf("pushed tag %s to remote", tagString)) + } + + return nil +} + +func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error { + directory := path.Join(config.APPS_DIR, recipe.Name) + repo, err := git.PlainOpen(directory) + if err != nil { + return err + } + + bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch) + if bumpType != 0 { + if (bumpType & (bumpType - 1)) != 0 { + fmt.Errorf("you can only use one of: --major, --minor, --patch.") + } + } + + if err := internal.PromptBumpType(tagString); err != nil { + return err + } + + var lastGitTag tagcmp.Tag + for _, tag := range tags { + parsed, err := tagcmp.Parse(tag) + if err != nil { + return err + } + + if (lastGitTag == tagcmp.Tag{}) { + lastGitTag = parsed + } else if parsed.IsGreaterThan(lastGitTag) { + lastGitTag = parsed + } + } + + newTag := lastGitTag + if bumpType > 0 { + if internal.Patch { + now, err := strconv.Atoi(newTag.Patch) + if err != nil { + return err + } + + newTag.Patch = strconv.Itoa(now + 1) + } else if internal.Minor { + now, err := strconv.Atoi(newTag.Minor) + if err != nil { + return err + } + + newTag.Patch = "0" + newTag.Minor = strconv.Itoa(now + 1) + } else if internal.Major { + now, err := strconv.Atoi(newTag.Major) + if err != nil { + return err + } + + newTag.Patch = "0" + newTag.Minor = "0" + newTag.Major = strconv.Itoa(now + 1) + } + } + + if err := commitRelease(recipe); err != nil { + logrus.Fatal(err) + } + + newTag.Metadata = mainAppVersion + newTagString := newTag.String() + + if err := tagRelease(newTagString, repo); err != nil { + logrus.Fatal(err) + } + + if err := pushRelease(newTagString, repo); err != nil { + logrus.Fatal(err) + } + + return nil +} diff --git a/pkg/git/commit.go b/pkg/git/commit.go index 981efe8da..b4ab636e0 100644 --- a/pkg/git/commit.go +++ b/pkg/git/commit.go @@ -2,20 +2,17 @@ package git import ( "fmt" - "path" - "coopcloud.tech/abra/pkg/config" "github.com/go-git/go-git/v5" "github.com/sirupsen/logrus" ) // Commit runs a git commit -func Commit(glob, commitMessage string, dryRun, push bool) error { +func Commit(repoPath, glob, commitMessage string, dryRun, push bool) error { if commitMessage == "" { return fmt.Errorf("no commit message specified?") } - repoPath := path.Join(config.ABRA_DIR, "catalogue") commitRepo, err := git.PlainOpen(repoPath) if err != nil { return err diff --git a/pkg/recipe/recipe.go b/pkg/recipe/recipe.go index 76d88aa14..33d6bab87 100644 --- a/pkg/recipe/recipe.go +++ b/pkg/recipe/recipe.go @@ -158,7 +158,7 @@ func EnsureVersion(recipeName, version string) error { return err } - logrus.Debugf("read '%s' as tags for recipe '%s'", strings.Join(parsedTags, ", "), recipeName) + logrus.Debugf("read %s as tags for recipe %s", strings.Join(parsedTags, ", "), recipeName) if tagRef.String() == "" { logrus.Warnf("%s recipe has no local tag: %s? this recipe version is not released?", recipeName, version) @@ -179,7 +179,7 @@ func EnsureVersion(recipeName, version string) error { return err } - logrus.Debugf("successfully checked '%s' out to '%s' in '%s'", recipeName, tagRef.Short(), recipeDir) + logrus.Debugf("successfully checked %s out to %s in %s", recipeName, tagRef.Short(), recipeDir) return nil } @@ -194,14 +194,14 @@ func EnsureLatest(recipeName string) error { } if !isClean { - return fmt.Errorf("'%s' has locally unstaged changes", recipeName) + return fmt.Errorf("%s has locally unstaged changes", recipeName) } if err := gitPkg.EnsureGitRepo(recipeDir); err != nil { return err } - logrus.Debugf("attempting to open git repository in '%s'", recipeDir) + logrus.Debugf("attempting to open git repository in %s", recipeDir) repo, err := git.PlainOpen(recipeDir) if err != nil { @@ -216,7 +216,7 @@ func EnsureLatest(recipeName string) error { branch := "master" if _, err := repo.Branch("master"); err != nil { if _, err := repo.Branch("main"); err != nil { - logrus.Debugf("failed to select branch in '%s'", path.Join(config.APPS_DIR, recipeName)) + logrus.Debugf("failed to select branch in %s", path.Join(config.APPS_DIR, recipeName)) return err } branch = "main" @@ -230,7 +230,7 @@ func EnsureLatest(recipeName string) error { } if err := worktree.Checkout(checkOutOpts); err != nil { - logrus.Debugf("failed to check out '%s' in '%s'", branch, recipeDir) + logrus.Debugf("failed to check out %s in %s", branch, recipeDir) return err } @@ -271,3 +271,22 @@ func GetRecipesLocal() ([]string, error) { return recipes, nil } + +// GetVersionLabelLocal retrieves the version label on the local recipe config +func GetVersionLabelLocal(recipe Recipe) (string, error) { + var label string + + for _, service := range recipe.Config.Services { + for label, value := range service.Deploy.Labels { + if strings.HasPrefix(label, "coop-cloud") { + return value, nil + } + } + } + + if label == "" { + return label, fmt.Errorf("unable to retrieve synced version label for %s", recipe.Name) + } + + return label, nil +}