|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "errors" |
| 5 | + "flag" |
| 6 | + "fmt" |
| 7 | + "os" |
| 8 | + "strconv" |
| 9 | + "strings" |
| 10 | + |
| 11 | + "github.com/go-git/go-git/v5" |
| 12 | + "github.com/go-git/go-git/v5/config" |
| 13 | + "github.com/go-git/go-git/v5/plumbing" |
| 14 | + "github.com/go-git/go-git/v5/plumbing/transport/ssh" |
| 15 | + "golang.org/x/mod/semver" |
| 16 | +) |
| 17 | + |
| 18 | +const ( |
| 19 | + sdkRepo = "[email protected]:stackitcloud/stackit-sdk-go.git" |
| 20 | + patch = "patch" |
| 21 | + minor = "minor" |
| 22 | + allServices = "all-services" |
| 23 | + core = "core" |
| 24 | + |
| 25 | + updateTypeFlag = "update-type" |
| 26 | + sshPrivateKeyFilePathFlag = "ssh-private-key-file-path" |
| 27 | + passwordFlag = "password" |
| 28 | + targetFlag = "target" |
| 29 | +) |
| 30 | + |
| 31 | +var ( |
| 32 | + updateTypes = []string{minor, patch} |
| 33 | + targets = []string{allServices, core} |
| 34 | + usage = "go run automatic_tag.go --update-type [minor|patch] --ssh-private-key-file-path path/to/private-key --password password --target [all-services|core]" |
| 35 | +) |
| 36 | + |
| 37 | +func main() { |
| 38 | + if err := run(); err != nil { |
| 39 | + fmt.Fprintf(os.Stderr, "Error: %s\n", err) |
| 40 | + os.Exit(1) |
| 41 | + } |
| 42 | +} |
| 43 | + |
| 44 | +func run() error { |
| 45 | + var updateType string |
| 46 | + var sshPrivateKeyFilePath string |
| 47 | + var password string |
| 48 | + var target string |
| 49 | + |
| 50 | + flag.StringVar(&updateType, updateTypeFlag, "", fmt.Sprintf("Update type, must be one of: %s (required)", strings.Join(updateTypes, ","))) |
| 51 | + flag.StringVar(&sshPrivateKeyFilePath, sshPrivateKeyFilePathFlag, "", "Path to the ssh private key (required)") |
| 52 | + flag.StringVar(&password, passwordFlag, "", "Password of the ssh private key (optional)") |
| 53 | + flag.StringVar(&target, targetFlag, allServices, fmt.Sprintf("Create tags for this target, must be one of %s (optional, default is %s)", strings.Join(targets, ","), allServices)) |
| 54 | + |
| 55 | + flag.Parse() |
| 56 | + |
| 57 | + validUpdateType := false |
| 58 | + for _, t := range updateTypes { |
| 59 | + if updateType == t { |
| 60 | + validUpdateType = true |
| 61 | + break |
| 62 | + } |
| 63 | + } |
| 64 | + if !validUpdateType { |
| 65 | + return fmt.Errorf("the provided update type `%s` is not valid, the valid values are: [%s]", updateType, strings.Join(updateTypes, ",")) |
| 66 | + } |
| 67 | + |
| 68 | + validTarget := false |
| 69 | + for _, t := range targets { |
| 70 | + if target == t { |
| 71 | + validTarget = true |
| 72 | + break |
| 73 | + } |
| 74 | + } |
| 75 | + if !validTarget { |
| 76 | + return fmt.Errorf("the provided target `%s` is not valid, the valid values are: [%s]", target, strings.Join(targets, ",")) |
| 77 | + } |
| 78 | + |
| 79 | + _, err := os.Stat(sshPrivateKeyFilePath) |
| 80 | + if err != nil { |
| 81 | + return fmt.Errorf("the provided private key file path %s is not valid: %w\nUsage: %s", sshPrivateKeyFilePath, err, usage) |
| 82 | + } |
| 83 | + |
| 84 | + err = automaticTagUpdate(updateType, sshPrivateKeyFilePath, password, target) |
| 85 | + if err != nil { |
| 86 | + return fmt.Errorf("updating tags: %s", err.Error()) |
| 87 | + } |
| 88 | + return nil |
| 89 | +} |
| 90 | + |
| 91 | +// automaticTagUpdate goes through all of the existing tags, gets the latest for the target, creates a new one according to the updateType and pushes them |
| 92 | +func automaticTagUpdate(updateType, sshPrivateKeyFilePath, password, target string) error { |
| 93 | + tempDir, err := os.MkdirTemp("", "") |
| 94 | + if err != nil { |
| 95 | + return fmt.Errorf("create temporary directory: %w", err) |
| 96 | + } |
| 97 | + |
| 98 | + defer func() { |
| 99 | + tempErr := os.RemoveAll(tempDir) |
| 100 | + if tempErr != nil { |
| 101 | + fmt.Printf("Warning: temporary directory %s could not be removed: %s", tempDir, tempErr.Error()) |
| 102 | + } |
| 103 | + }() |
| 104 | + |
| 105 | + publicKeys, err := ssh.NewPublicKeysFromFile("git", sshPrivateKeyFilePath, password) |
| 106 | + if err != nil { |
| 107 | + return fmt.Errorf("get public keys from private key file: %w", err) |
| 108 | + } |
| 109 | + |
| 110 | + r, err := git.PlainClone(tempDir, false, &git.CloneOptions{ |
| 111 | + Auth: publicKeys, |
| 112 | + URL: sdkRepo, |
| 113 | + RecurseSubmodules: git.DefaultSubmoduleRecursionDepth, |
| 114 | + }) |
| 115 | + if err != nil { |
| 116 | + return fmt.Errorf("clone SDK repo: %w", err) |
| 117 | + } |
| 118 | + |
| 119 | + tagrefs, err := r.Tags() |
| 120 | + if err != nil { |
| 121 | + return fmt.Errorf("get tags: %w", err) |
| 122 | + } |
| 123 | + |
| 124 | + latestTags := map[string]string{} |
| 125 | + err = tagrefs.ForEach(func(t *plumbing.Reference) error { |
| 126 | + latestTags, err = storeLatestTag(t, latestTags, target) |
| 127 | + if err != nil { |
| 128 | + return fmt.Errorf("store latest tag: %w", err) |
| 129 | + } |
| 130 | + return nil |
| 131 | + }) |
| 132 | + if err != nil { |
| 133 | + return fmt.Errorf("iterate over existing tags: %w", err) |
| 134 | + } |
| 135 | + |
| 136 | + for module, version := range latestTags { |
| 137 | + updatedVersion, err := computeUpdatedVersion(version, updateType) |
| 138 | + if err != nil { |
| 139 | + fmt.Printf("Error computing updated version for %s with version %s, this tag will be skipped: %s\n", module, version, err.Error()) |
| 140 | + continue |
| 141 | + } |
| 142 | + |
| 143 | + var newTag string |
| 144 | + switch target { |
| 145 | + case core: |
| 146 | + if module != "core" { |
| 147 | + return fmt.Errorf("%s target was provided but there is a stored latest tag from another service: %s", target, module) |
| 148 | + } |
| 149 | + newTag = fmt.Sprintf("core/%s", updatedVersion) |
| 150 | + case allServices: |
| 151 | + newTag = fmt.Sprintf("services/%s/%s", module, updatedVersion) |
| 152 | + default: |
| 153 | + fmt.Printf("Error computing updated version for %s with version %s, this tag will be skipped: target %s not supported in version increment, fix the script\n", module, version, target) |
| 154 | + continue |
| 155 | + } |
| 156 | + |
| 157 | + err = createTag(r, newTag) |
| 158 | + if err != nil { |
| 159 | + fmt.Printf("Create tag %s returned error: %s\n", newTag, err) |
| 160 | + continue |
| 161 | + } |
| 162 | + fmt.Printf("Created tag %s\n", newTag) |
| 163 | + } |
| 164 | + |
| 165 | + err = pushTags(r, publicKeys) |
| 166 | + if err != nil { |
| 167 | + return fmt.Errorf("push tags: %w", err) |
| 168 | + } |
| 169 | + return nil |
| 170 | +} |
| 171 | + |
| 172 | +// storeLatestTag receives a tag in the form of a plumbing.Reference and a map with the latest tag per service |
| 173 | +// It checks if the tag is part of the current target (if it is belonging to a service or to core), |
| 174 | +// checks if it is newer than the current latest tag stored in the map and if it is, updates latestTags and returns it |
| 175 | +func storeLatestTag(t *plumbing.Reference, latestTags map[string]string, target string) (map[string]string, error) { |
| 176 | + tagName, _ := strings.CutPrefix(t.Name().String(), "refs/tags/") |
| 177 | + splitTag := strings.Split(tagName, "/") |
| 178 | + |
| 179 | + switch target { |
| 180 | + case core: |
| 181 | + if len(splitTag) != 2 || splitTag[0] != "core" { |
| 182 | + return latestTags, nil |
| 183 | + } |
| 184 | + |
| 185 | + version := splitTag[1] |
| 186 | + if semver.Prerelease(version) != "" { |
| 187 | + return latestTags, nil |
| 188 | + } |
| 189 | + |
| 190 | + // invalid (or empty) semantic version are considered less than a valid one |
| 191 | + if semver.Compare(latestTags["core"], version) == -1 { |
| 192 | + latestTags["core"] = version |
| 193 | + } |
| 194 | + case allServices: |
| 195 | + if len(splitTag) != 3 || splitTag[0] != "services" { |
| 196 | + return latestTags, nil |
| 197 | + } |
| 198 | + |
| 199 | + service := splitTag[1] |
| 200 | + version := splitTag[2] |
| 201 | + if semver.Prerelease(version) != "" { |
| 202 | + return latestTags, nil |
| 203 | + } |
| 204 | + |
| 205 | + // invalid (or empty) semantic version are considered less than a valid one |
| 206 | + if semver.Compare(latestTags[service], version) == -1 { |
| 207 | + latestTags[service] = version |
| 208 | + } |
| 209 | + default: |
| 210 | + return nil, fmt.Errorf("target not supported in storeLatestTag, fix the script") |
| 211 | + } |
| 212 | + return latestTags, nil |
| 213 | +} |
| 214 | + |
| 215 | +// computeUpdatedVersion returns the updated version according to the update type |
| 216 | +// example: for version v0.1.1 and updateType minor, it returns v0.2.0 |
| 217 | +func computeUpdatedVersion(version, updateType string) (string, error) { |
| 218 | + canonicalVersion := semver.Canonical(version) |
| 219 | + splitVersion := strings.Split(canonicalVersion, ".") |
| 220 | + if len(splitVersion) != 3 { |
| 221 | + return "", fmt.Errorf("invalid canonical version") |
| 222 | + } |
| 223 | + |
| 224 | + switch updateType { |
| 225 | + case patch: |
| 226 | + patchNumber, err := strconv.Atoi(splitVersion[2]) |
| 227 | + if err != nil { |
| 228 | + return "", fmt.Errorf("couldnt convert patch number to int") |
| 229 | + } |
| 230 | + updatedPatchNumber := patchNumber + 1 |
| 231 | + splitVersion[2] = fmt.Sprint(updatedPatchNumber) |
| 232 | + case minor: |
| 233 | + minorNumber, err := strconv.Atoi(splitVersion[1]) |
| 234 | + if err != nil { |
| 235 | + return "", fmt.Errorf("couldnt convert minor number to int") |
| 236 | + } |
| 237 | + updatedPatchNumber := minorNumber + 1 |
| 238 | + splitVersion[1] = fmt.Sprint(updatedPatchNumber) |
| 239 | + splitVersion[2] = "0" |
| 240 | + default: |
| 241 | + return "", fmt.Errorf("update type not supported in version increment, fix the script") |
| 242 | + } |
| 243 | + |
| 244 | + updatedVersion := strings.Join(splitVersion, ".") |
| 245 | + return updatedVersion, nil |
| 246 | +} |
| 247 | + |
| 248 | +func createTag(r *git.Repository, tag string) error { |
| 249 | + h, err := r.Head() |
| 250 | + if err != nil { |
| 251 | + return fmt.Errorf("get HEAD: %w", err) |
| 252 | + } |
| 253 | + _, err = r.CreateTag(tag, h.Hash(), nil) |
| 254 | + if err != nil { |
| 255 | + return fmt.Errorf("create tag: %w", err) |
| 256 | + } |
| 257 | + return nil |
| 258 | +} |
| 259 | + |
| 260 | +func pushTags(r *git.Repository, publicKeys *ssh.PublicKeys) error { |
| 261 | + po := &git.PushOptions{ |
| 262 | + Auth: publicKeys, |
| 263 | + RemoteName: "origin", |
| 264 | + Progress: os.Stdout, |
| 265 | + RefSpecs: []config.RefSpec{config.RefSpec("refs/tags/*:refs/tags/*")}, |
| 266 | + } |
| 267 | + err := r.Push(po) |
| 268 | + |
| 269 | + if err != nil { |
| 270 | + if errors.Is(err, git.NoErrAlreadyUpToDate) { |
| 271 | + return fmt.Errorf("origin remote was up to date, no push done") |
| 272 | + } |
| 273 | + return fmt.Errorf("push to remote origin: %w", err) |
| 274 | + } |
| 275 | + |
| 276 | + return nil |
| 277 | +} |
0 commit comments