675 lines
18 KiB
Go
675 lines
18 KiB
Go
package recipe
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"coopcloud.tech/abra/cli/internal"
|
|
"coopcloud.tech/abra/pkg/autocomplete"
|
|
"coopcloud.tech/abra/pkg/formatter"
|
|
gitPkg "coopcloud.tech/abra/pkg/git"
|
|
"coopcloud.tech/abra/pkg/i18n"
|
|
"coopcloud.tech/abra/pkg/log"
|
|
recipePkg "coopcloud.tech/abra/pkg/recipe"
|
|
"coopcloud.tech/tagcmp"
|
|
"github.com/AlecAivazis/survey/v2"
|
|
"github.com/distribution/reference"
|
|
"github.com/go-git/go-git/v5"
|
|
"github.com/go-git/go-git/v5/plumbing"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// Errors
|
|
var errEmptyVersionsInCatalogue = errors.New(i18n.G("catalogue versions list is unexpectedly empty"))
|
|
|
|
// translators: `abra recipe release` aliases. use a comma separated list of
|
|
// aliases with no spaces in between
|
|
var recipeReleaseAliases = i18n.G("rl")
|
|
|
|
var RecipeReleaseCommand = &cobra.Command{
|
|
// translators: `recipe release` command
|
|
Use: i18n.G("release <recipe> [version] [flags]"),
|
|
Aliases: strings.Split(recipeReleaseAliases, ","),
|
|
// translators: Short description for `recipe release` command
|
|
Short: i18n.G("Release a new recipe version"),
|
|
Long: i18n.G(`Create a new version of a recipe.
|
|
|
|
These versions are then published on the Co-op Cloud recipe catalogue. These
|
|
versions take the following form:
|
|
|
|
a.b.c+x.y.z
|
|
|
|
Where the "a.b.c" part is a semantic version determined by the maintainer. The
|
|
"x.y.z" part is the image tag of the recipe "app" service (the main container
|
|
which contains the software to be used, by naming convention).
|
|
|
|
We maintain a semantic versioning scheme ("a.b.c") alongside the recipe
|
|
versioning scheme ("x.y.z") in order to maximise the chances that the nature of
|
|
recipe updates are properly communicated. I.e. developers of an app might
|
|
publish a minor version but that might lead to changes in the recipe which are
|
|
major and therefore require intervention while doing the upgrade work.
|
|
|
|
This command will publish your new release to git.coopcloud.tech. This
|
|
requires that you have permission to git push to these repositories and have
|
|
your SSH keys configured on your account. Enable ssh-agent and make sure to add
|
|
your private key and enter your passphrase beforehand.
|
|
|
|
eval ` + "`ssh-agent`" + `
|
|
ssh-add ~/.ssh/<my-ssh-private-key-for-git-coopcloud-tech>`),
|
|
Example: ` # publish release
|
|
eval ` + "`ssh-agent`" + `
|
|
ssh-add ~/.ssh/id_ed25519
|
|
abra recipe release gitea`,
|
|
Args: cobra.RangeArgs(1, 2),
|
|
ValidArgsFunction: func(
|
|
cmd *cobra.Command,
|
|
args []string,
|
|
toComplete string,
|
|
) ([]string, cobra.ShellCompDirective) {
|
|
switch l := len(args); l {
|
|
case 0:
|
|
return autocomplete.RecipeNameComplete()
|
|
case 1:
|
|
return autocomplete.RecipeVersionComplete(args[0])
|
|
default:
|
|
return nil, cobra.ShellCompDirectiveDefault
|
|
}
|
|
},
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
recipe := internal.ValidateRecipe(args, cmd.Name())
|
|
|
|
imagesTmp, err := GetImageVersions(recipe)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
mainApp, err := internal.GetMainAppImage(recipe)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
mainAppVersion := imagesTmp[mainApp]
|
|
if mainAppVersion == "" {
|
|
log.Fatal(i18n.G("main app service version for %s is empty?", recipe.Name))
|
|
}
|
|
|
|
isClean, err := gitPkg.IsClean(recipe.Dir)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if !isClean {
|
|
log.Fatal(i18n.G("working directory not clean in %s, aborting", recipe.Dir))
|
|
}
|
|
|
|
tags, err := recipe.Tags()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var tagString string
|
|
if len(args) == 2 {
|
|
tagString = args[1]
|
|
}
|
|
|
|
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
|
|
log.Fatal(i18n.G("cannot specify tag and bump type at the same time"))
|
|
}
|
|
|
|
if len(tags) == 0 && tagString == "" {
|
|
log.Warn(i18n.G("no git tags found for %s", recipe.Name))
|
|
if internal.NoInput {
|
|
log.Fatal(i18n.G("unable to continue, input required for initial version"))
|
|
}
|
|
fmt.Println(i18n.G(`
|
|
The following options are two types of initial semantic version that you can
|
|
pick for %s that will be published in the recipe catalogue. This follows the
|
|
semver convention (more on https://semver.org), here is a short cheatsheet
|
|
|
|
0.1.0: development release, still hacking. when you make a major upgrade
|
|
you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
|
|
using the "x" part when things are stable.
|
|
|
|
1.0.0: public release, assumed to be working. you already have a stable
|
|
and reliable deployment of this app and feel relatively confident
|
|
about it.
|
|
|
|
If you want people to be able alpha test your current config for %s but don't
|
|
think it is quite reliable, go with 0.1.0 and people will know that things are
|
|
likely to change.
|
|
|
|
`, recipe.Name, recipe.Name))
|
|
var chosenVersion string
|
|
edPrompt := &survey.Select{
|
|
Message: i18n.G("which version do you want to begin with?"),
|
|
Options: []string{"0.1.0", "1.0.0"},
|
|
}
|
|
|
|
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
tagString = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
|
|
}
|
|
|
|
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
|
|
|
|
catl, err := recipePkg.ReadRecipeCatalogue(false)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
changesTable, err := formatter.CreateTable()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
latestRelease := tags[len(tags)-1]
|
|
latestRecipeVersion, err := getLatestVersion(recipe, catl)
|
|
if err != nil && err != errEmptyVersionsInCatalogue {
|
|
log.Fatal(err)
|
|
}
|
|
changesTable.Headers(i18n.G("SERVICE"), latestRelease, i18n.G("PROPOSED CHANGES"))
|
|
|
|
allRecipeVersions := catl[recipe.Name].Versions
|
|
for _, recipeVersion := range allRecipeVersions {
|
|
if serviceVersions, ok := recipeVersion[latestRecipeVersion]; ok {
|
|
for serviceName := range serviceVersions {
|
|
serviceMeta := serviceVersions[serviceName]
|
|
|
|
existingImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, serviceMeta.Tag)
|
|
newImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, imagesTmp[serviceMeta.Image])
|
|
|
|
if existingImageTag == newImageTag {
|
|
continue
|
|
}
|
|
|
|
changesTable.Row([]string{serviceName, existingImageTag, newImageTag}...)
|
|
}
|
|
}
|
|
}
|
|
|
|
changeOverview := changesTable.Render()
|
|
|
|
if err := internal.PromptBumpType("", latestRelease, changeOverview); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
if tagString == "" {
|
|
repo, err := git.PlainOpen(recipe.Dir)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
var lastGitTag tagcmp.Tag
|
|
iter, err := repo.Tags()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if err := iter.ForEach(func(ref *plumbing.Reference) error {
|
|
obj, err := repo.TagObject(ref.Hash())
|
|
if err != nil {
|
|
log.Fatal(i18n.G("tag at commit %s is unannotated or otherwise broken", ref.Hash()))
|
|
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 {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
// 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 {
|
|
log.Fatal(i18n.G("you can only use one version flag: --major, --minor or --patch"))
|
|
}
|
|
}
|
|
|
|
newTag := lastGitTag
|
|
if bumpType > 0 {
|
|
if internal.Patch {
|
|
now, err := strconv.Atoi(newTag.Patch)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
newTag.Patch = strconv.Itoa(now + 1)
|
|
} else if internal.Minor {
|
|
now, err := strconv.Atoi(newTag.Minor)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
newTag.Patch = "0"
|
|
newTag.Minor = strconv.Itoa(now + 1)
|
|
} else if internal.Major {
|
|
now, err := strconv.Atoi(newTag.Major)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
newTag.Patch = "0"
|
|
newTag.Minor = "0"
|
|
newTag.Major = strconv.Itoa(now + 1)
|
|
}
|
|
}
|
|
|
|
newTag.Metadata = mainAppVersion
|
|
log.Debug(i18n.G("choosing %s as new version for %s", newTag.String(), recipe.Name))
|
|
tagString = newTag.String()
|
|
}
|
|
|
|
if _, err := tagcmp.Parse(tagString); err != nil {
|
|
log.Fatal(i18n.G("invalid version %s specified", tagString))
|
|
}
|
|
|
|
mainService := "app"
|
|
label := i18n.G("coop-cloud.${STACK_NAME}.version=%s", tagString)
|
|
if !internal.Dry {
|
|
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
} else {
|
|
log.Info(i18n.G("dry run: not syncing label %s for recipe %s", tagString, recipe.Name))
|
|
}
|
|
|
|
for _, tag := range tags {
|
|
previousTagLeftHand := strings.Split(tag, "+")[0]
|
|
newTagStringLeftHand := strings.Split(tagString, "+")[0]
|
|
if previousTagLeftHand == newTagStringLeftHand {
|
|
log.Fatal(i18n.G("%s+... conflicts with a previous release: %s", newTagStringLeftHand, tag))
|
|
}
|
|
}
|
|
|
|
repo, err := git.PlainOpen(recipe.Dir)
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
preCommitHead, err := repo.Head()
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
|
|
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
|
|
if cleanErr := cleanTag(recipe, tagString); cleanErr != nil {
|
|
log.Fatal(cleanErr)
|
|
}
|
|
if cleanErr := cleanCommit(recipe, preCommitHead); cleanErr != nil {
|
|
log.Fatal(cleanErr)
|
|
}
|
|
log.Fatal(err)
|
|
}
|
|
},
|
|
}
|
|
|
|
// GetImageVersions retrieves image versions for a recipe
|
|
func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
|
|
services := make(map[string]string)
|
|
|
|
config, err := recipe.GetComposeConfig(nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
missingTag := false
|
|
for _, service := range config.Services {
|
|
if service.Image == "" {
|
|
continue
|
|
}
|
|
|
|
img, err := reference.ParseNormalizedNamed(service.Image)
|
|
if err != nil {
|
|
return services, err
|
|
}
|
|
|
|
path := reference.Path(img)
|
|
|
|
path = formatter.StripTagMeta(path)
|
|
|
|
var tag string
|
|
switch img.(type) {
|
|
case reference.NamedTagged:
|
|
tag = img.(reference.NamedTagged).Tag()
|
|
case reference.Named:
|
|
if service.Name == "app" {
|
|
missingTag = true
|
|
}
|
|
continue
|
|
}
|
|
|
|
services[path] = tag
|
|
}
|
|
|
|
if missingTag {
|
|
return services, errors.New(i18n.G("app service is missing image tag?"))
|
|
}
|
|
|
|
return services, nil
|
|
}
|
|
|
|
// createReleaseFromTag creates a new release based on a supplied recipe version string
|
|
func createReleaseFromTag(recipe recipePkg.Recipe, tagString, mainAppVersion string) error {
|
|
var err error
|
|
|
|
repo, err := git.PlainOpen(recipe.Dir)
|
|
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 tagString == "" {
|
|
tagString = fmt.Sprintf("%s+%s", tag.String(), mainAppVersion)
|
|
}
|
|
|
|
if err := addReleaseNotes(recipe, tagString); err != nil {
|
|
return errors.New(i18n.G("failed to add release notes: %s", err.Error()))
|
|
}
|
|
|
|
if err := commitRelease(recipe, tagString); err != nil {
|
|
return errors.New(i18n.G("failed to commit changes: %s", err.Error()))
|
|
}
|
|
|
|
if err := tagRelease(tagString, repo); err != nil {
|
|
return errors.New(i18n.G("failed to tag release: %s", err.Error()))
|
|
}
|
|
|
|
if err := pushRelease(recipe, tagString); err != nil {
|
|
return errors.New(i18n.G("failed to publish new release: %s", err.Error()))
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// btoi converts a boolean value into an integer
|
|
func btoi(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// getTagCreateOptions constructs git tag create options
|
|
func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
|
|
msg := i18n.G("chore: publish %s release", tag)
|
|
return git.CreateTagOptions{Message: msg}, nil
|
|
}
|
|
|
|
// addReleaseNotes checks if the release/next release note exists and moves the
|
|
// file to release/<tag>.
|
|
func addReleaseNotes(recipe recipePkg.Recipe, tag string) error {
|
|
releaseDir := path.Join(recipe.Dir, "release")
|
|
if _, err := os.Stat(releaseDir); errors.Is(err, os.ErrNotExist) {
|
|
if err := os.Mkdir(releaseDir, 0755); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
tagReleaseNotePath := path.Join(releaseDir, tag)
|
|
if _, err := os.Stat(tagReleaseNotePath); err == nil {
|
|
// Release note for current tag already exist exists.
|
|
return nil
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
var addNextAsReleaseNotes bool
|
|
|
|
nextReleaseNotePath := path.Join(releaseDir, "next")
|
|
if _, err := os.Stat(nextReleaseNotePath); err == nil {
|
|
// release/next note exists. Move it to release/<tag>
|
|
if internal.Dry {
|
|
log.Debug(i18n.G("dry run: move release note from 'next' to %s", tag))
|
|
return nil
|
|
}
|
|
|
|
if !internal.NoInput {
|
|
prompt := &survey.Confirm{
|
|
Message: i18n.G("use release note in release/next?"),
|
|
}
|
|
|
|
if err := survey.AskOne(prompt, &addNextAsReleaseNotes); err != nil {
|
|
return err
|
|
}
|
|
|
|
if !addNextAsReleaseNotes {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if err := os.Rename(nextReleaseNotePath, tagReleaseNotePath); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gitPkg.Add(recipe.Dir, path.Join("release", "next"), internal.Dry); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry); err != nil {
|
|
return err
|
|
}
|
|
} else if !errors.Is(err, os.ErrNotExist) {
|
|
return err
|
|
}
|
|
|
|
// NOTE(d1): No release note exists for the current release. Or, we've
|
|
// already used release/next as the release note
|
|
if internal.NoInput || addNextAsReleaseNotes {
|
|
return nil
|
|
}
|
|
|
|
prompt := &survey.Input{
|
|
Message: i18n.G("add release note? (leave empty to skip)"),
|
|
}
|
|
|
|
var releaseNote string
|
|
if err := survey.AskOne(prompt, &releaseNote); err != nil {
|
|
return err
|
|
}
|
|
|
|
if releaseNote == "" {
|
|
return nil
|
|
}
|
|
|
|
if err := os.WriteFile(tagReleaseNotePath, []byte(releaseNote), 0o644); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func commitRelease(recipe recipePkg.Recipe, tag string) error {
|
|
if internal.Dry {
|
|
log.Debug(i18n.G("dry run: no changes committed"))
|
|
return nil
|
|
}
|
|
|
|
isClean, err := gitPkg.IsClean(recipe.Dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if isClean {
|
|
if !internal.Dry {
|
|
return errors.New(i18n.G("no changes discovered in %s, nothing to publish?", recipe.Dir))
|
|
}
|
|
}
|
|
|
|
msg := fmt.Sprintf("chore: publish %s release", tag)
|
|
if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func tagRelease(tagString string, repo *git.Repository) error {
|
|
if internal.Dry {
|
|
log.Debug(i18n.G("dry run: no git tag created (%s)", tagString))
|
|
return nil
|
|
}
|
|
|
|
head, err := repo.Head()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
createTagOptions, err := getTagCreateOptions(tagString)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
_, err = repo.CreateTag(tagString, head.Hash(), &createTagOptions)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hash := formatter.SmallSHA(head.Hash().String())
|
|
log.Debug(i18n.G("created tag %s at %s", tagString, hash))
|
|
|
|
return nil
|
|
}
|
|
|
|
func pushRelease(recipe recipePkg.Recipe, tagString string) error {
|
|
if internal.Dry {
|
|
log.Info(i18n.G("dry run: no changes published"))
|
|
return nil
|
|
}
|
|
|
|
if os.Getenv("SSH_AUTH_SOCK") == "" {
|
|
return errors.New(i18n.G("ssh-agent not found. see \"abra recipe release --help\" and try again"))
|
|
}
|
|
|
|
if err := recipe.Push(internal.Dry); err != nil {
|
|
return err
|
|
}
|
|
|
|
url := fmt.Sprintf("%s/src/tag/%s", recipe.GitURL, tagString)
|
|
log.Info(i18n.G("new release published: %s", url))
|
|
|
|
return nil
|
|
}
|
|
|
|
// cleanCommit soft removes the latest release commit. No change are lost the
|
|
// the commit itself is removed. This is the equivalent of `git reset HEAD~1`.
|
|
func cleanCommit(recipe recipePkg.Recipe, head *plumbing.Reference) error {
|
|
repo, err := git.PlainOpen(recipe.Dir)
|
|
if err != nil {
|
|
return errors.New(i18n.G("unable to open repo in %s: %s", recipe.Dir, err))
|
|
}
|
|
|
|
worktree, err := repo.Worktree()
|
|
if err != nil {
|
|
return errors.New(i18n.G("unable to open work tree in %s: %s", recipe.Dir, err))
|
|
}
|
|
|
|
opts := &git.ResetOptions{Commit: head.Hash(), Mode: git.MixedReset}
|
|
if err := worktree.Reset(opts); err != nil {
|
|
return errors.New(i18n.G("unable to soft reset %s: %s", recipe.Dir, err))
|
|
}
|
|
|
|
log.Debug(i18n.G("removed freshly created commit"))
|
|
|
|
return nil
|
|
}
|
|
|
|
// cleanTag removes a freshly created tag
|
|
func cleanTag(recipe recipePkg.Recipe, tag string) error {
|
|
repo, err := git.PlainOpen(recipe.Dir)
|
|
if err != nil {
|
|
return errors.New(i18n.G("unable to open repo in %s: %s", recipe.Dir, err))
|
|
}
|
|
|
|
if err := repo.DeleteTag(tag); err != nil {
|
|
if !strings.Contains(err.Error(), "not found") {
|
|
return errors.New(i18n.G("unable to delete tag %s: %s", tag, err))
|
|
}
|
|
}
|
|
|
|
log.Debug(i18n.G("removed freshly created tag %s", tag))
|
|
|
|
return nil
|
|
}
|
|
|
|
func getLatestVersion(recipe recipePkg.Recipe, catl recipePkg.RecipeCatalogue) (string, error) {
|
|
versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if len(versions) > 0 {
|
|
return versions[len(versions)-1], nil
|
|
}
|
|
return "", errEmptyVersionsInCatalogue
|
|
}
|
|
|
|
func init() {
|
|
RecipeReleaseCommand.Flags().BoolVarP(
|
|
&internal.Dry,
|
|
i18n.G("dry-run"),
|
|
i18n.G("r"),
|
|
false,
|
|
i18n.G("report changes that would be made"),
|
|
)
|
|
|
|
RecipeReleaseCommand.Flags().BoolVarP(
|
|
&internal.Major,
|
|
i18n.G("major"),
|
|
i18n.G("x"),
|
|
false,
|
|
i18n.G("increase the major part of the version"),
|
|
)
|
|
|
|
RecipeReleaseCommand.Flags().BoolVarP(
|
|
&internal.Minor,
|
|
i18n.G("minor"),
|
|
i18n.G("y"),
|
|
false,
|
|
i18n.G("increase the minor part of the version"),
|
|
)
|
|
|
|
RecipeReleaseCommand.Flags().BoolVarP(
|
|
&internal.Patch,
|
|
i18n.G("patch"),
|
|
i18n.G("z"),
|
|
false,
|
|
i18n.G("increase the patch part of the version"),
|
|
)
|
|
}
|