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" "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" ) var RecipeReleaseCommand = &cobra.Command{ // translators: `recipe release` command Use: i18n.G("release [version] [flags]"), Aliases: []string{i18n.G("rl")}, // 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. 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. Enable ssh-agent and make sure to add your private key and enter your passphrase beforehand. eval ` + "`ssh-agent`" + ` ssh-add ~/.ssh/`), Example: ` # publish release eval ` + "`ssh-agent`" + ` ssh-add ~/.ssh/id_ed25519 abra recipe release gitea -p`, 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)) } var tagString string if len(args) == 2 { tagString = args[1] } if tagString != "" { if _, err := tagcmp.Parse(tagString); err != nil { log.Fatal(i18n.G("cannot parse %s, invalid tag specified?", tagString)) } } if (internal.Major || internal.Minor || internal.Patch) && tagString != "" { log.Fatal(i18n.G("cannot specify tag and bump type at the same time")) } repo, err := git.PlainOpen(recipe.Dir) if err != nil { log.Fatal(err) } preCommitHead, err := repo.Head() if err != nil { log.Fatal(err) } if tagString != "" { 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) } } 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.Info(i18n.G("%s currently has these unstaged changes 👇", recipe.Name)) if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil { log.Fatal(err) } } if len(tags) > 0 { log.Warn(i18n.G("previous git tags detected, assuming new semver release")) if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); 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) } } else { log.Warn(i18n.G("no tag specified and no previous tag available for %s, assuming initial release", recipe.Name)) 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) } } 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, 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 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 { 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/. 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/ 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 recipe.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 recipe.Recipe, tagString string) error { if internal.Dry { log.Info(i18n.G("dry run: no changes published")) return nil } if !publish && !internal.NoInput { prompt := &survey.Confirm{ Message: i18n.G("publish new release?"), } if err := survey.AskOne(prompt, &publish); err != nil { return err } } if publish { 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)) } else { log.Info(i18n.G("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 errors.New(i18n.G("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 internal.Major || internal.Minor || internal.Patch { newTag.Metadata = mainAppVersion tagString = newTag.String() } if lastGitTag.String() == tagString { return errors.New(i18n.G("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString)) } if !internal.NoInput { prompt := &survey.Confirm{ Message: i18n.G("current: %s, new: %s, correct?", lastGitTag, tagString), } var ok bool if err := survey.AskOne(prompt, &ok); err != nil { return err } if !ok { return errors.New(i18n.G("exiting as requested")) } } 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 } // 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 recipe.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 recipe.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 getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) { initTag, err := recipe.GetVersionLabelLocal() if err != nil { return "", err } if initTag == "" { return "", errors.New(i18n.G("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name)) } log.Warn(i18n.G("discovered %s as currently synced recipe label", initTag)) if prompt && !internal.NoInput { var response bool prompt := &survey.Confirm{Message: i18n.G("use %s as the new version?", initTag)} if err := survey.AskOne(prompt, &response); err != nil { return "", err } if !response { return "", errors.New(i18n.G("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, 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"), ) RecipeReleaseCommand.Flags().BoolVarP( &publish, i18n.G("publish"), i18n.G("p"), false, i18n.G("publish changes to git.coopcloud.tech"), ) }