From 3685cad0673fb1aeec2ff55fec7c327fa644e812 Mon Sep 17 00:00:00 2001 From: iexos Date: Fri, 6 Feb 2026 19:51:58 +0100 Subject: [PATCH 1/4] feat!: always publish on `recipe release` --- cli/recipe/release.go | 47 +++++++-------------------- tests/integration/helpers/recipe.bash | 17 +++++++++- tests/integration/recipe_release.bats | 11 ++++--- 3 files changed, 34 insertions(+), 41 deletions(-) diff --git a/cli/recipe/release.go b/cli/recipe/release.go index a1516711..0b45084f 100644 --- a/cli/recipe/release.go +++ b/cli/recipe/release.go @@ -50,7 +50,7 @@ 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 +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. @@ -60,12 +60,13 @@ your private key and enter your passphrase beforehand. Example: ` # publish release eval ` + "`ssh-agent`" + ` ssh-add ~/.ssh/id_ed25519 - abra recipe release gitea -p`, + abra recipe release gitea`, Args: cobra.RangeArgs(1, 2), ValidArgsFunction: func( cmd *cobra.Command, args []string, - toComplete string) ([]string, cobra.ShellCompDirective) { + toComplete string, + ) ([]string, cobra.ShellCompDirective) { switch l := len(args); l { case 0: return autocomplete.RecipeNameComplete() @@ -445,31 +446,17 @@ func pushRelease(recipe recipe.Recipe, tagString string) error { 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 os.Getenv("SSH_AUTH_SOCK") == "" { + return errors.New(i18n.G("ssh-agent not found. see \"abra recipe release --help\" and try again")) } - 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")) + 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 } @@ -638,10 +625,6 @@ func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) { return initTag, nil } -var ( - publish bool -) - func init() { RecipeReleaseCommand.Flags().BoolVarP( &internal.Dry, @@ -674,12 +657,4 @@ func init() { 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"), - ) } diff --git a/tests/integration/helpers/recipe.bash b/tests/integration/helpers/recipe.bash index 4d430cd6..50c17a95 100644 --- a/tests/integration/helpers/recipe.bash +++ b/tests/integration/helpers/recipe.bash @@ -5,11 +5,22 @@ _latest_release(){ } _fetch_recipe() { + # clone first to a bare repo which will serve as origin-ssh + # this enables simulating git push in recipe release if [[ ! -d "$ABRA_DIR/recipes/$TEST_RECIPE" ]]; then + run mkdir -p "$ABRA_DIR/origin-recipes" + assert_success + + run git clone "https://git.coopcloud.tech/toolshed/$TEST_RECIPE" "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" --bare + assert_success + run mkdir -p "$ABRA_DIR/recipes" assert_success - run git clone "https://git.coopcloud.tech/toolshed/$TEST_RECIPE" "$ABRA_DIR/recipes/$TEST_RECIPE" + run git clone "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" "$ABRA_DIR/recipes/$TEST_RECIPE" + assert_success + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" remote add origin-ssh "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" assert_success fi } @@ -19,6 +30,10 @@ _reset_recipe(){ assert_success assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE" + run rm -rf "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" + assert_success + assert_not_exists "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" + _fetch_recipe } diff --git a/tests/integration/recipe_release.bats b/tests/integration/recipe_release.bats index c9857278..9b0af446 100644 --- a/tests/integration/recipe_release.bats +++ b/tests/integration/recipe_release.bats @@ -55,7 +55,7 @@ teardown() { run $ABRA recipe release "$TEST_RECIPE" --no-input --patch assert_success - assert_output --partial 'no -p/--publish passed, not publishing' + assert_output --partial 'INFO new release published:' run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag --list assert_success @@ -86,7 +86,7 @@ teardown() { run $ABRA recipe release "$TEST_RECIPE" --no-input --minor assert_success - assert_output --partial 'no -p/--publish passed, not publishing' + assert_output --partial 'INFO new release published:' run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag --list assert_success @@ -106,7 +106,7 @@ teardown() { run $ABRA recipe release "$TEST_RECIPE" --no-input --patch assert_success - assert_output --partial 'no -p/--publish passed, not publishing' + assert_output --partial 'INFO new release published:' run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rm foo assert_failure @@ -116,6 +116,9 @@ teardown() { @test "release with next release note" { _mkfile "$ABRA_DIR/recipes/$TEST_RECIPE/release/next" "those are some release notes for the next release" + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" checkout main + assert_success + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" add release/next assert_success @@ -127,7 +130,7 @@ teardown() { run $ABRA recipe release "$TEST_RECIPE" --no-input --minor assert_success - assert_output --partial 'no -p/--publish passed, not publishing' + assert_output --partial 'new release published:' assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/release/next" assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/release/0.4.0+1.21.0" -- 2.49.0 From 4a6ad29434505ab0e1278eca7d97e4b4d19bb03c Mon Sep 17 00:00:00 2001 From: iexos Date: Fri, 6 Feb 2026 21:30:41 +0100 Subject: [PATCH 2/4] feat!: merge recipe sync into recipe release --- cli/recipe/release.go | 407 ++++++++++--------- cli/recipe/{sync_test.go => release_test.go} | 2 +- cli/recipe/sync.go | 313 -------------- cli/run.go | 1 - tests/integration/recipe_release.bats | 92 ++++- tests/integration/recipe_sync.bats | 200 --------- 6 files changed, 286 insertions(+), 729 deletions(-) rename cli/recipe/{sync_test.go => release_test.go} (94%) delete mode 100644 cli/recipe/sync.go delete mode 100644 tests/integration/recipe_sync.bats diff --git a/cli/recipe/release.go b/cli/recipe/release.go index 0b45084f..7876424e 100644 --- a/cli/recipe/release.go +++ b/cli/recipe/release.go @@ -14,7 +14,7 @@ import ( gitPkg "coopcloud.tech/abra/pkg/git" "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" - "coopcloud.tech/abra/pkg/recipe" + recipePkg "coopcloud.tech/abra/pkg/recipe" "coopcloud.tech/tagcmp" "github.com/AlecAivazis/survey/v2" "github.com/distribution/reference" @@ -23,6 +23,9 @@ import ( "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") @@ -94,19 +97,198 @@ your private key and enter your passphrase beforehand. log.Fatal(i18n.G("main app service version for %s is empty?", recipe.Name)) } + tags, err := recipe.Tags() + if err != nil { + log.Fatal(err) + } + 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")) + } + + 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 (internal.Major || internal.Minor || internal.Patch) && tagString != "" { - log.Fatal(i18n.G("cannot specify tag and bump type at the same time")) + 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) @@ -119,84 +301,20 @@ your private key and enter your passphrase beforehand. 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) + 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) } - } - - tags, err := recipe.Tags() - if err != nil { log.Fatal(err) } - - labelVersion, err := getLabelVersion(recipe, false) - if err != nil { - log.Fatal(err) - } - - for _, tag := range tags { - previousTagLeftHand := strings.Split(tag, "+")[0] - newTagStringLeftHand := strings.Split(labelVersion, "+")[0] - if previousTagLeftHand == newTagStringLeftHand { - log.Fatal(i18n.G("%s+... conflicts with a previous release: %s", newTagStringLeftHand, tag)) - } - } - - if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) { - tagString = labelVersion - } - - 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) { +func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) { services := make(map[string]string) config, err := recipe.GetComposeConfig(nil) @@ -240,7 +358,7 @@ func GetImageVersions(recipe recipe.Recipe) (map[string]string, error) { } // createReleaseFromTag creates a new release based on a supplied recipe version string -func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error { +func createReleaseFromTag(recipe recipePkg.Recipe, tagString, mainAppVersion string) error { var err error repo, err := git.PlainOpen(recipe.Dir) @@ -303,7 +421,7 @@ func getTagCreateOptions(tag string) (git.CreateTagOptions, error) { // addReleaseNotes checks if the release/next release note exists and moves the // file to release/. -func addReleaseNotes(recipe recipe.Recipe, tag string) error { +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 { @@ -388,7 +506,7 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error { return nil } -func commitRelease(recipe recipe.Recipe, tag string) error { +func commitRelease(recipe recipePkg.Recipe, tag string) error { if internal.Dry { log.Debug(i18n.G("dry run: no changes committed")) return nil @@ -440,7 +558,7 @@ func tagRelease(tagString string, repo *git.Repository) error { return nil } -func pushRelease(recipe recipe.Recipe, tagString string) error { +func pushRelease(recipe recipePkg.Recipe, tagString string) error { if internal.Dry { log.Info(i18n.G("dry run: no changes published")) return nil @@ -460,106 +578,9 @@ func pushRelease(recipe recipe.Recipe, tagString string) error { 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 { +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)) @@ -581,7 +602,7 @@ func cleanCommit(recipe recipe.Recipe, head *plumbing.Reference) error { } // cleanTag removes a freshly created tag -func cleanTag(recipe recipe.Recipe, tag string) error { +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)) @@ -598,31 +619,15 @@ func cleanTag(recipe recipe.Recipe, tag string) error { return nil } -func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) { - initTag, err := recipe.GetVersionLabelLocal() +func getLatestVersion(recipe recipePkg.Recipe, catl recipePkg.RecipeCatalogue) (string, error) { + versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl) 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)) + if len(versions) > 0 { + return versions[len(versions)-1], nil } - - 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 + return "", errEmptyVersionsInCatalogue } func init() { diff --git a/cli/recipe/sync_test.go b/cli/recipe/release_test.go similarity index 94% rename from cli/recipe/sync_test.go rename to cli/recipe/release_test.go index 2e5df357..dc27e329 100644 --- a/cli/recipe/sync_test.go +++ b/cli/recipe/release_test.go @@ -11,7 +11,7 @@ func TestGetLatestVersionReturnsErrorWhenVersionsIsEmpty(t *testing.T) { recipe := recipePkg.Recipe{} catalogue := recipePkg.RecipeCatalogue{} _, err := getLatestVersion(recipe, catalogue) - assert.Equal(t, err, emptyVersionsInCatalogue) + assert.Equal(t, err, errEmptyVersionsInCatalogue) } func TestGetLatestVersionReturnsLastVersion(t *testing.T) { diff --git a/cli/recipe/sync.go b/cli/recipe/sync.go deleted file mode 100644 index 9a888497..00000000 --- a/cli/recipe/sync.go +++ /dev/null @@ -1,313 +0,0 @@ -package recipe - -import ( - "errors" - "fmt" - "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/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - "github.com/spf13/cobra" -) - -// Errors -var emptyVersionsInCatalogue = errors.New(i18n.G("catalogue versions list is unexpectedly empty")) - -// translators: `abra recipe reset` aliases. use a comma separated list of -// aliases with no spaces in between -var recipeSyncAliases = i18n.G("s") - -var RecipeSyncCommand = &cobra.Command{ - // translators: `recipe sync` command - Use: i18n.G("sync [version] [flags]"), - Aliases: strings.Split(recipeSyncAliases, ","), - // translators: Short description for `recipe sync` command - Short: i18n.G("Sync recipe version label"), - Long: i18n.G(`Generate labels for the main recipe service. - -By convention, the service named "app" using the following format: - - coop-cloud.${STACK_NAME}.version= - -Where [version] can be specifed on the command-line or Abra can attempt to -auto-generate it for you. The configuration will be updated on the -local file system.`), - 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.ShellCompDirectiveError - } - }, - Run: func(cmd *cobra.Command, args []string) { - recipe := internal.ValidateRecipe(args, cmd.Name()) - - mainApp, err := internal.GetMainAppImage(recipe) - if err != nil { - log.Fatal(err) - } - - imagesTmp, err := GetImageVersions(recipe) - if err != nil { - log.Fatal(err) - } - - mainAppVersion := imagesTmp[mainApp] - - tags, err := recipe.Tags() - if err != nil { - log.Fatal(err) - } - - var nextTag string - if len(args) == 2 { - nextTag = args[1] - } - - if len(tags) == 0 && nextTag == "" { - 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) - } - - nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion) - } - - if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) { - var changeOverview string - - 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 != emptyVersionsInCatalogue { - 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 nextTag == "" { - 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)) - nextTag = newTag.String() - } - - if _, err := tagcmp.Parse(nextTag); err != nil { - log.Fatal(i18n.G("invalid version %s specified", nextTag)) - } - - mainService := "app" - label := i18n.G("coop-cloud.${STACK_NAME}.version=%s", nextTag) - 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", nextTag, recipe.Name)) - } - - 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) - } - } - }, -} - -func init() { - RecipeSyncCommand.Flags().BoolVarP( - &internal.Dry, - i18n.G("dry-run"), - i18n.G("r"), - false, - i18n.G("report changes that would be made"), - ) - - RecipeSyncCommand.Flags().BoolVarP( - &internal.Major, - i18n.G("major"), - i18n.G("x"), - false, - i18n.G("increase the major part of the version"), - ) - - RecipeSyncCommand.Flags().BoolVarP( - &internal.Minor, - i18n.G("minor"), - i18n.G("y"), - false, - i18n.G("increase the minor part of the version"), - ) - - RecipeSyncCommand.Flags().BoolVarP( - &internal.Patch, - i18n.G("patch"), - i18n.G("z"), - false, - i18n.G("increase the patch part of the version"), - ) -} - -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 "", emptyVersionsInCatalogue -} diff --git a/cli/run.go b/cli/run.go index 524afe01..fcfc310c 100644 --- a/cli/run.go +++ b/cli/run.go @@ -245,7 +245,6 @@ Config: recipe.RecipeNewCommand, recipe.RecipeReleaseCommand, recipe.RecipeResetCommand, - recipe.RecipeSyncCommand, recipe.RecipeUpgradeCommand, recipe.RecipeVersionCommand, ) diff --git a/tests/integration/recipe_release.bats b/tests/integration/recipe_release.bats index 9b0af446..03d7da59 100644 --- a/tests/integration/recipe_release.bats +++ b/tests/integration/recipe_release.bats @@ -1,18 +1,18 @@ #!/usr/bin/env bash -setup_file(){ +setup_file() { load "$PWD/tests/integration/helpers/common" _common_setup _add_server _new_app } -teardown_file(){ +teardown_file() { _rm_server _reset_recipe } -setup(){ +setup() { load "$PWD/tests/integration/helpers/common" _common_setup _set_git_author @@ -21,6 +21,14 @@ setup(){ teardown() { _reset_recipe _reset_tags + if [[ -d "$ABRA_DIR/recipes/foobar" ]]; then + run rm -rf "$ABRA_DIR/recipes/foobar" + assert_success + fi + if [[ -d "$ABRA_DIR/origin-recipes/foobar.git" ]]; then + run rm -rf "$ABRA_DIR/origin-recipes/foobar.git" + assert_success + fi } @test "validate recipe argument" { @@ -32,6 +40,7 @@ teardown() { } @test "release patch bump" { + # test will fail, rework with recipe upgrade run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch assert_success @@ -63,6 +72,7 @@ teardown() { } @test "release minor bump" { + # test will fail, rework with recipe upgrade run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor assert_success @@ -94,6 +104,7 @@ teardown() { } @test "unknown files not committed" { + # test will fail, rework with recipe upgrade run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch assert_success @@ -125,10 +136,7 @@ teardown() { run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" commit -m "added some release notes" assert_success - run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch - assert_success - - run $ABRA recipe release "$TEST_RECIPE" --no-input --minor + run $ABRA recipe release "$TEST_RECIPE" --no-input --minor assert_success assert_output --partial 'new release published:' @@ -149,14 +157,72 @@ teardown() { assert_success assert_output --regexp 'nginx:1.29.1' - run sed -i "s/0.2.0+1.21.0/0.2.0+1.29.1/g" "$ABRA_DIR/recipes/$TEST_RECIPE/compose.yml" + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" commit -am "updated nginx" assert_success - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.2\.0\+1\.29\.1' - - run $ABRA recipe release "$TEST_RECIPE" --no-input --minor + run $ABRA recipe release "$TEST_RECIPE" --no-input "0.2.0+1.29.1" assert_failure assert_output --partial '0.2.0+... conflicts with a previous release: 0.2.0+1.21.0' } + +@test "error if recipe release --no-input and no initial version" { + _remove_tags + + run $ABRA recipe release "$TEST_RECIPE" --no-input --patch + assert_failure + assert_output --partial 'unable to continue' + assert_output --partial 'initial version' +} + +@test "recipe release without input fails with prompt" { + run $ABRA recipe new foobar + assert_success + assert_exists "$ABRA_DIR/recipes/foobar" + + run $ABRA recipe release foobar --no-input --patch + assert_failure + assert_output --partial "input required for initial version" +} + +@test "release new recipe: fail without input" { + run $ABRA recipe new foobar + assert_success + assert_exists "$ABRA_DIR/recipes/foobar" + + run bash -c "$ABRA recipe release foobar --no-input" + assert_failure + assert_output --partial 'unable to continue, input required for initial version' +} + +# note: piping 0.1.0 from stdin is not testable right now because release notes also wants input +# survey lib used for prompts breaks multi-line stdin for multi-prompt +@test "release new recipe: development release" { + run $ABRA recipe new foobar + assert_success + assert_exists "$ABRA_DIR/recipes/foobar" + + # fake origin + git clone "$ABRA_DIR/recipes/foobar" "$ABRA_DIR/origin-recipes/foobar.git" --bare + assert_success + + run git -C "$ABRA_DIR/recipes/foobar" remote add origin-ssh "$ABRA_DIR/origin-recipes/foobar.git" + assert_success + + run bash -c "$ABRA recipe release foobar 0.1.0+1.2.0 --no-input" + assert_success + assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.1\.0\+1\.2.*' +} + +@test "release newly created recipe with no version label" { + run $ABRA recipe new foobar + assert_success + assert_exists "$ABRA_DIR/recipes/foobar" + + run sed -i 's/- "coop-cloud.${STACK_NAME}.version="/#- "coop-cloud.${STACK_NAME}.version="/g' \ + "$ABRA_DIR/recipes/foobar/compose.yml" + assert_success + + run bash -c "echo 0.1.0 | $ABRA recipe release foobar --patch" + assert_failure + assert_output --partial "automagic insertion not supported yet" +} diff --git a/tests/integration/recipe_sync.bats b/tests/integration/recipe_sync.bats deleted file mode 100644 index c82bbc3b..00000000 --- a/tests/integration/recipe_sync.bats +++ /dev/null @@ -1,200 +0,0 @@ -#!/usr/bin/env bash - -setup_file(){ - load "$PWD/tests/integration/helpers/common" - _common_setup - _add_server - _new_app -} - -teardown_file(){ - _rm_server -} - -setup(){ - load "$PWD/tests/integration/helpers/common" - _common_setup -} - -teardown(){ - _reset_recipe - _reset_tags - if [[ -d "$ABRA_DIR/recipes/foobar" ]]; then - run rm -rf "$ABRA_DIR/recipes/foobar" - assert_success - fi -} - -@test "validate recipe argument" { - run $ABRA recipe sync --no-input - assert_failure - - run $ABRA recipe sync DOESNTEXIST --no-input - assert_failure -} - -@test "allow unstaged changes" { - run echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo" - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" status - assert_success - assert_output --partial 'foo' - - run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch - assert_success - - assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" - assert_equal "$(_git_status)" "M compose.yml ?? foo" - - run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo" - assert_success - assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" -} - -@test "detect unstaged label changes" { - run $ABRA recipe fetch "$TEST_RECIPE" - assert_success - - run $ABRA recipe sync "$TEST_RECIPE" --patch - assert_success - - run $ABRA recipe sync "$TEST_RECIPE" --patch - assert_success - assert_output --partial 'is already set, nothing to do?' -} - -@test "sync patch label bump" { - run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --partial 'image: nginx:1.21.6' - - # NOTE(d1): ensure the latest tag is the one we expect - _remove_tags - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \ - -a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0" - assert_success - - run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.3\.1\+1\.2.*' -} - -@test "sync minor label bump" { - run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --regexp 'image: nginx:1.2.*' - - # NOTE(d1): ensure the latest tag is the one we expect - _remove_tags - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \ - -a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0" - assert_success - - run $ABRA recipe sync "$TEST_RECIPE" --no-input --minor - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.4\.0\+1\.2.*' -} - -@test "error if --no-input and no initial version" { - _remove_tags - - run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch - assert_failure - assert_output --partial 'unable to continue' - assert_output --partial 'initial version' -} - -@test "output label sync only once" { - run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --regexp 'image: nginx:1.2.*' - - run $ABRA recipe sync "$TEST_RECIPE" --no-input --minor - assert_success - assert_line --index 0 --partial 'synced label' - refute_line --index 1 --partial 'synced label' -} - -@test "sync with no tags or previous release" { - _remove_tags - - run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --partial 'image: nginx:1.21.6' - - # NOTE(d1): ensure the latest tag is the one we expect - _remove_tags - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \ - -a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0" - assert_success - - run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch - assert_success - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.3\.1\+1\.2.*' -} - -@test "sync recipe without input fails with prompt" { - run $ABRA recipe new foobar - assert_success - assert_exists "$ABRA_DIR/recipes/foobar" - - run $ABRA recipe sync foobar --no-input --patch - assert_failure - assert_output --partial "input required for initial version" -} - -@test "sync new recipe: development release" { - run $ABRA recipe new foobar - assert_success - assert_exists "$ABRA_DIR/recipes/foobar" - - run bash -c "echo 0.1.0 | $ABRA recipe sync foobar --patch" - assert_success - assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.1\.0\+1\.2.*' -} - -@test "sync new recipe: public release" { - run $ABRA recipe new foobar - assert_success - assert_exists "$ABRA_DIR/recipes/foobar" - - run bash -c "echo 1.0.0 | $ABRA recipe sync foobar --patch" - assert_success - assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=1\.0\.0\+1\.2.*' -} - -@test "sync newly created recipe with no version label" { - run $ABRA recipe new foobar - assert_success - assert_exists "$ABRA_DIR/recipes/foobar" - - run sed -i 's/- "coop-cloud.${STACK_NAME}.version="/#- "coop-cloud.${STACK_NAME}.version="/g' \ - "$ABRA_DIR/recipes/foobar/compose.yml" - assert_success - - run bash -c "echo 0.1.0 | $ABRA recipe sync foobar --patch" - assert_failure - assert_output --partial "automagic insertion not supported yet" -} -- 2.49.0 From 5ae19d404493938854eeceafa1ee4d92bcd857f1 Mon Sep 17 00:00:00 2001 From: iexos Date: Sat, 7 Feb 2026 00:18:28 +0100 Subject: [PATCH 3/4] feat!: require clean working copy for recipe release cmd --- cli/recipe/release.go | 9 +++++++ tests/integration/recipe_release.bats | 37 +++++++++++++++++---------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/cli/recipe/release.go b/cli/recipe/release.go index 7876424e..d96178b1 100644 --- a/cli/recipe/release.go +++ b/cli/recipe/release.go @@ -97,6 +97,15 @@ your private key and enter your passphrase beforehand. 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) diff --git a/tests/integration/recipe_release.bats b/tests/integration/recipe_release.bats index 03d7da59..f3e2955c 100644 --- a/tests/integration/recipe_release.bats +++ b/tests/integration/recipe_release.bats @@ -103,25 +103,31 @@ teardown() { assert_output --regexp '0\.4\.0\+1\.2.*' } -@test "unknown files not committed" { - # test will fail, rework with recipe upgrade - run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch +@test "release with unstaged changes" { + run bash -c 'echo "# unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/compose.yml"' assert_success - run bash -c 'echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo"' - assert_success - assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo" - - run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch - assert_success + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet + assert_failure run $ABRA recipe release "$TEST_RECIPE" --no-input --patch - assert_success - assert_output --partial 'INFO new release published:' - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rm foo assert_failure - assert_output --partial "fatal: pathspec 'foo' did not match any files" + assert_output --partial "working directory not clean" +} + +@test "release with staged changes" { + run bash -c 'echo "# staged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/compose.yml"' + assert_success + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" add compose.yml + assert_success + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet --cached + assert_failure + + run $ABRA recipe release "$TEST_RECIPE" --no-input --patch + assert_failure + assert_output --partial "working directory not clean" } @test "release with next release note" { @@ -222,6 +228,9 @@ teardown() { "$ABRA_DIR/recipes/foobar/compose.yml" assert_success + run git -C "$ABRA_DIR/recipes/foobar" commit -am "updated nginx" + assert_success + run bash -c "echo 0.1.0 | $ABRA recipe release foobar --patch" assert_failure assert_output --partial "automagic insertion not supported yet" -- 2.49.0 From 4d075a6bb42369f7a5fc0494526625fc3b5a0a25 Mon Sep 17 00:00:00 2001 From: iexos Date: Mon, 9 Feb 2026 19:49:57 +0100 Subject: [PATCH 4/4] feat: optionally commit changes with recipe upgrade --- cli/recipe/upgrade.go | 38 +++++++++++++++++++++++++-- tests/integration/recipe_release.bats | 26 +++--------------- tests/integration/recipe_upgrade.bats | 34 ++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 24 deletions(-) diff --git a/cli/recipe/upgrade.go b/cli/recipe/upgrade.go index f3776b0d..9e458d0e 100644 --- a/cli/recipe/upgrade.go +++ b/cli/recipe/upgrade.go @@ -62,7 +62,8 @@ interface.`), ValidArgsFunction: func( cmd *cobra.Command, args []string, - toComplete string) ([]string, cobra.ShellCompDirective) { + toComplete string, + ) ([]string, cobra.ShellCompDirective) { return autocomplete.RecipeNameComplete() }, Run: func(cmd *cobra.Command, args []string) { @@ -335,12 +336,37 @@ interface.`), if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil { log.Fatal(err) } + + if !internal.NoInput && !createCommit { + prompt := &survey.Confirm{ + Message: i18n.G("commit changes?"), + Default: true, + } + + if err := survey.AskOne(prompt, &createCommit); err != nil { + log.Fatal(err) + } + } + + if createCommit { + msg := i18n.G("chore: update image tags") + if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil { + log.Fatal(err) + } + log.Info(i18n.G("committed changes as '%s'", msg)) + } + + } else { + if createCommit { + log.Warn(i18n.G("no changes, skip creating commit")) + } } }, } var ( - allTags bool + allTags bool + createCommit bool ) func init() { @@ -383,4 +409,12 @@ func init() { false, i18n.G("list all tags, not just upgrades"), ) + + RecipeUpgradeCommand.Flags().BoolVarP( + &createCommit, + i18n.G("commit"), + i18n.GC("c", "recipe upgrade"), + false, + i18n.G("commit changes"), + ) } diff --git a/tests/integration/recipe_release.bats b/tests/integration/recipe_release.bats index f3e2955c..e62dbbd6 100644 --- a/tests/integration/recipe_release.bats +++ b/tests/integration/recipe_release.bats @@ -40,11 +40,10 @@ teardown() { } @test "release patch bump" { - # test will fail, rework with recipe upgrade - run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch + run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch --commit assert_success - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" show assert_success assert_output --partial 'image: nginx:1.21.6' @@ -54,14 +53,6 @@ teardown() { -a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0" assert_success - run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch - assert_success - assert_output --partial 'synced label' - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --partial 'coop-cloud.${STACK_NAME}.version=0.3.1+1.21.6' - run $ABRA recipe release "$TEST_RECIPE" --no-input --patch assert_success assert_output --partial 'INFO new release published:' @@ -72,11 +63,10 @@ teardown() { } @test "release minor bump" { - # test will fail, rework with recipe upgrade - run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor + run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor --commit assert_success - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" show assert_success assert_output --regexp 'image: nginx:1.2.*' @@ -86,14 +76,6 @@ teardown() { -a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0" assert_success - run $ABRA recipe sync "$TEST_RECIPE" --no-input --minor - assert_success - assert_output --partial 'synced label' - - run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff - assert_success - assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.4\.0\+1\.2.*' - run $ABRA recipe release "$TEST_RECIPE" --no-input --minor assert_success assert_output --partial 'INFO new release published:' diff --git a/tests/integration/recipe_upgrade.bats b/tests/integration/recipe_upgrade.bats index 0ab89759..527bb9d1 100644 --- a/tests/integration/recipe_upgrade.bats +++ b/tests/integration/recipe_upgrade.bats @@ -106,3 +106,37 @@ teardown(){ assert_success assert_output --regexp 'image: nginx:1.2.*' } + +@test "upgrade and commit" { + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD + assert_success + expected_count="$((output + 1))" + + run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor --commit + assert_success + assert_output --partial 'committed changes as' + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet + assert_success + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD + assert_success + assert_output "$expected_count" +} + +@test "upgrade nothing, skip commit" { + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD + assert_success + expected_count="$output" + + run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --commit + assert_success + assert_output --partial "no changes, skip creating commit" + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet + assert_success + + run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD + assert_success + assert_output "$expected_count" +} -- 2.49.0