package recipe import ( "fmt" "path" "strconv" "strings" abraFormatter "coopcloud.tech/abra/cli/formatter" "coopcloud.tech/abra/cli/internal" "coopcloud.tech/abra/pkg/autocomplete" "coopcloud.tech/abra/pkg/config" gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/recipe" recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/tagcmp" "github.com/AlecAivazis/survey/v2" "github.com/docker/distribution/reference" "github.com/go-git/go-git/v5" configPkg "github.com/go-git/go-git/v5/config" "github.com/sirupsen/logrus" "github.com/urfave/cli/v2" ) var recipeReleaseCommand = &cli.Command{ Name: "release", Usage: "Release a new recipe version", Aliases: []string{"rl"}, ArgsUsage: " []", Description: ` This command is used to specify a new tag for a recipe. These tags are used to identify different versions of the recipe and are published on the Co-op Cloud recipe catalogue. These tags take the following form: a.b.c+x.y.z Where the "a.b.c" part is a semantic version determined by the maintainer. And the "x.y.z" part is the image tag of the recipe "app" service (the main container which contains the software to be used). We maintain a semantic versioning scheme ("a.b.c") alongside the libre app versioning scheme in order to maximise the chances that the nature of recipe updates are properly communicated. Abra does its best to read the "a.b.c" version scheme and communicate what action needs to be taken when performing different operations such as an update or a rollback of an app. You may invoke this command in "wizard" mode and be prompted for input: abra recipe release gitea `, Flags: []cli.Flag{ internal.DryFlag, internal.MajorFlag, internal.MinorFlag, internal.PatchFlag, internal.PushFlag, internal.CommitFlag, internal.CommitMessageFlag, internal.TagMessageFlag, }, BashComplete: autocomplete.RecipeNameComplete, Action: func(c *cli.Context) error { recipe := internal.ValidateRecipeWithPrompt(c) imagesTmp, err := getImageVersions(recipe) if err != nil { logrus.Fatal(err) } mainApp := internal.GetMainApp(recipe) mainAppVersion := imagesTmp[mainApp] 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.Fatalf("cannot parse %s, invalid tag specified?", tagString) } } if (internal.Major || internal.Minor || internal.Patch) && tagString != "" { logrus.Fatal("cannot specify tag and bump type at the same time") } if tagString != "" { if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil { logrus.Fatal(err) } } tags, err := recipe.Tags() if err != nil { return err } if len(tags) > 0 { logrus.Warnf("previous git tags detected, assuming this is a new semver release") if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil { logrus.Fatal(err) } } else { 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 }, } // getImageVersions retrieves image versions for a recipe func getImageVersions(recipe recipe.Recipe) (map[string]string, error) { var services = make(map[string]string) for _, service := range recipe.Config.Services { if service.Image == "" { continue } img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return services, err } path := reference.Path(img) if strings.Contains(path, "library") { path = strings.Split(path, "/")[1] } var tag string switch img.(type) { case reference.NamedTagged: tag = img.(reference.NamedTagged).Tag() case reference.Named: return services, fmt.Errorf("%s service is missing image tag?", path) } services[path] = 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 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) } if tagString == "" { 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 { return 1 } 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 && internal.Commit { prompt := &survey.Input{ Message: "commit message", Default: "chore: publish new 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.Infof("dry run: no git tag created (%s)", tagString) 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 { tagRef := fmt.Sprintf("+refs/tags/%s:refs/tags/%s", tagString, tagString) pushOpts := &git.PushOptions{ RefSpecs: []configPkg.RefSpec{ configPkg.RefSpec(tagRef), }, } if err := repo.Push(pushOpts); 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 { return 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 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 }