forked from toolshed/abra
		
	
		
			
				
	
	
		
			614 lines
		
	
	
		
			14 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			614 lines
		
	
	
		
			14 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/log"
 | |
| 	"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/spf13/cobra"
 | |
| )
 | |
| 
 | |
| var RecipeReleaseCommand = &cobra.Command{
 | |
| 	Use:     "release <recipe> [version] [flags]",
 | |
| 	Aliases: []string{"rl"},
 | |
| 	Short:   "Release a new recipe version",
 | |
| 	Long: `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.
 | |
| 
 | |
| Publish your new release to git.coopcloud.tech with "--publish/-p". This
 | |
| requires that you have permission to git push to these repositories and have
 | |
| your SSH keys configured on your account.`,
 | |
| 	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.Fatalf("main app service version for %s is empty?", recipe.Name)
 | |
| 		}
 | |
| 
 | |
| 		var tagString string
 | |
| 		if len(args) == 2 {
 | |
| 			tagString = args[1]
 | |
| 		}
 | |
| 
 | |
| 		if tagString != "" {
 | |
| 			if _, err := tagcmp.Parse(tagString); err != nil {
 | |
| 				log.Fatalf("cannot parse %s, invalid tag specified?", tagString)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
 | |
| 			log.Fatal("cannot specify tag and bump type at the same time")
 | |
| 		}
 | |
| 
 | |
| 		if tagString != "" {
 | |
| 			if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		tags, err := recipe.Tags()
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
 | |
| 			var err error
 | |
| 			tagString, err = getLabelVersion(recipe, false)
 | |
| 			if err != nil {
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		isClean, err := gitPkg.IsClean(recipe.Dir)
 | |
| 		if err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		if !isClean {
 | |
| 			log.Infof("%s currently has these unstaged changes 👇", recipe.Name)
 | |
| 			if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		if len(tags) > 0 {
 | |
| 			log.Warnf("previous git tags detected, assuming this is a new semver release")
 | |
| 			if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 		} else {
 | |
| 			log.Warnf("no tag specified and no previous tag available for %s, assuming this is the initial release", recipe.Name)
 | |
| 
 | |
| 			if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
 | |
| 				if cleanUpErr := cleanUpTag(recipe, tagString); err != nil {
 | |
| 					log.Fatal(cleanUpErr)
 | |
| 				}
 | |
| 				log.Fatal(err)
 | |
| 			}
 | |
| 		}
 | |
| 
 | |
| 		return
 | |
| 	},
 | |
| }
 | |
| 
 | |
| // getImageVersions retrieves image versions for a recipe
 | |
| func getImageVersions(recipe recipe.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, fmt.Errorf("app service is missing image tag?")
 | |
| 	}
 | |
| 
 | |
| 	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
 | |
| 
 | |
| 	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 {
 | |
| 		log.Fatal(err)
 | |
| 	}
 | |
| 
 | |
| 	if err := commitRelease(recipe, tagString); err != nil {
 | |
| 		log.Fatal(err)
 | |
| 	}
 | |
| 
 | |
| 	if err := tagRelease(tagString, repo); err != nil {
 | |
| 		log.Fatal(err)
 | |
| 	}
 | |
| 
 | |
| 	if err := pushRelease(recipe, tagString); err != nil {
 | |
| 		log.Fatal(err)
 | |
| 	}
 | |
| 
 | |
| 	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 := fmt.Sprintf("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 recipe.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.Debugf("dry run: move release note from 'next' to %s", tag)
 | |
| 			return nil
 | |
| 		}
 | |
| 
 | |
| 		if !internal.NoInput {
 | |
| 			prompt := &survey.Confirm{
 | |
| 				Message: "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: "Release Note (leave empty for no release note)",
 | |
| 	}
 | |
| 
 | |
| 	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 recipe.Recipe, tag string) error {
 | |
| 	if internal.Dry {
 | |
| 		log.Debugf("dry run: no changes committed")
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	isClean, err := gitPkg.IsClean(recipe.Dir)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if isClean {
 | |
| 		if !internal.Dry {
 | |
| 			return fmt.Errorf("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.Debugf("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.Debugf(fmt.Sprintf("created tag %s at %s", tagString, hash))
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func pushRelease(recipe recipe.Recipe, tagString string) error {
 | |
| 	if internal.Dry {
 | |
| 		log.Info("dry run: no changes published")
 | |
| 		return nil
 | |
| 	}
 | |
| 
 | |
| 	if !publish && !internal.NoInput {
 | |
| 		prompt := &survey.Confirm{
 | |
| 			Message: "publish new release?",
 | |
| 		}
 | |
| 
 | |
| 		if err := survey.AskOne(prompt, &publish); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if publish {
 | |
| 		if err := recipe.Push(internal.Dry); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 		url := fmt.Sprintf("%s/src/tag/%s", recipe.GitURL, tagString)
 | |
| 		log.Infof("new release published: %s", url)
 | |
| 	} else {
 | |
| 		log.Info("no -p/--publish passed, not publishing")
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
 | |
| 	repo, err := git.PlainOpen(recipe.Dir)
 | |
| 	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 {
 | |
| 			return fmt.Errorf("you can only use one of: --major, --minor, --patch")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	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 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 tagString == "" {
 | |
| 		if err := internal.PromptBumpType(tagString, lastGitTag.String()); err != nil {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if internal.Major || internal.Minor || internal.Patch {
 | |
| 		newTag.Metadata = mainAppVersion
 | |
| 		tagString = newTag.String()
 | |
| 	}
 | |
| 
 | |
| 	if lastGitTag.String() == tagString {
 | |
| 		log.Fatalf("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)
 | |
| 	}
 | |
| 
 | |
| 	if !internal.NoInput {
 | |
| 		prompt := &survey.Confirm{
 | |
| 			Message: fmt.Sprintf("current: %s, new: %s, correct?", lastGitTag, tagString),
 | |
| 		}
 | |
| 
 | |
| 		var ok bool
 | |
| 		if err := survey.AskOne(prompt, &ok); err != nil {
 | |
| 			log.Fatal(err)
 | |
| 		}
 | |
| 
 | |
| 		if !ok {
 | |
| 			log.Fatal("exiting as requested")
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	if err := addReleaseNotes(recipe, tagString); err != nil {
 | |
| 		log.Fatal(err)
 | |
| 	}
 | |
| 
 | |
| 	if err := commitRelease(recipe, tagString); err != nil {
 | |
| 		log.Fatalf("failed to commit changes: %s", err.Error())
 | |
| 	}
 | |
| 
 | |
| 	if err := tagRelease(tagString, repo); err != nil {
 | |
| 		log.Fatalf("failed to tag release: %s", err.Error())
 | |
| 	}
 | |
| 
 | |
| 	if err := pushRelease(recipe, tagString); err != nil {
 | |
| 		log.Fatalf("failed to publish new release: %s", err.Error())
 | |
| 	}
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| // cleanUpTag removes a freshly created tag
 | |
| func cleanUpTag(recipe recipe.Recipe, tag string) error {
 | |
| 	repo, err := git.PlainOpen(recipe.Dir)
 | |
| 	if err != nil {
 | |
| 		return err
 | |
| 	}
 | |
| 
 | |
| 	if err := repo.DeleteTag(tag); err != nil {
 | |
| 		if !strings.Contains(err.Error(), "not found") {
 | |
| 			return err
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	log.Debugf("removed freshly created tag %s", tag)
 | |
| 
 | |
| 	return nil
 | |
| }
 | |
| 
 | |
| func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
 | |
| 	initTag, err := recipe.GetVersionLabelLocal()
 | |
| 	if err != nil {
 | |
| 		return "", err
 | |
| 	}
 | |
| 
 | |
| 	if initTag == "" {
 | |
| 		log.Fatalf("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)
 | |
| 	}
 | |
| 
 | |
| 	log.Warnf("discovered %s as currently synced recipe label", initTag)
 | |
| 
 | |
| 	if prompt && !internal.NoInput {
 | |
| 		var response bool
 | |
| 		prompt := &survey.Confirm{Message: fmt.Sprintf("use %s as the new version?", initTag)}
 | |
| 		if err := survey.AskOne(prompt, &response); err != nil {
 | |
| 			return "", err
 | |
| 		}
 | |
| 
 | |
| 		if !response {
 | |
| 			return "", fmt.Errorf("please fix your synced label for %s and re-run this command", recipe.Name)
 | |
| 		}
 | |
| 	}
 | |
| 
 | |
| 	return initTag, nil
 | |
| }
 | |
| 
 | |
| var (
 | |
| 	publish bool
 | |
| )
 | |
| 
 | |
| func init() {
 | |
| 	RecipeReleaseCommand.Flags().BoolVarP(
 | |
| 		&internal.Dry,
 | |
| 		"dry-run",
 | |
| 		"r",
 | |
| 		false,
 | |
| 		"report changes that would be made",
 | |
| 	)
 | |
| 
 | |
| 	RecipeReleaseCommand.Flags().BoolVarP(
 | |
| 		&internal.Major,
 | |
| 		"major",
 | |
| 		"x",
 | |
| 		false,
 | |
| 		"increase the major part of the version",
 | |
| 	)
 | |
| 
 | |
| 	RecipeReleaseCommand.Flags().BoolVarP(
 | |
| 		&internal.Minor,
 | |
| 		"minor",
 | |
| 		"y",
 | |
| 		false,
 | |
| 		"increase the minor part of the version",
 | |
| 	)
 | |
| 
 | |
| 	RecipeReleaseCommand.Flags().BoolVarP(
 | |
| 		&internal.Patch,
 | |
| 		"patch",
 | |
| 		"z",
 | |
| 		false,
 | |
| 		"increase the patch part of the version",
 | |
| 	)
 | |
| 
 | |
| 	RecipeReleaseCommand.Flags().BoolVarP(
 | |
| 		&publish,
 | |
| 		"publish",
 | |
| 		"p",
 | |
| 		false,
 | |
| 		"publish changes to git.coopcloud.tech",
 | |
| 	)
 | |
| 
 | |
| }
 |