Skip to content

Commit b98ecff

Browse files
Automatic tag helper script (#335)
* First draft of the automatic tag script * Create and push tags * Finish script, add to lint * Unit tests * Replace url * Rename variables * Remove key * Skip scripts for go1.18 * Test and lint scripts only for go 1.21 * Remove go work sum * Updated go.sum * Add make commands * Add password argument * Sanity check * Changes after review * Fix lint * Replace url * Remove leftover condition * Update scripts/automatic_tag.go Co-authored-by: João Palet <[email protected]> * Moved logic out of computeLatestVersion --------- Co-authored-by: João Palet <[email protected]>
1 parent e9fcd91 commit b98ecff

File tree

7 files changed

+661
-1
lines changed

7 files changed

+661
-1
lines changed

.github/workflows/ci.yaml

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,13 @@ jobs:
2525
run: |
2626
make lint
2727
scripts/check-sync-tidy.sh
28-
28+
- name: Lint scripts
29+
if: ${{ matrix.go-version == '1.21' }}
30+
run: |
31+
make lint-scripts
2932
- name: Test
3033
run: make test
34+
- name: Test scripts
35+
if: ${{ matrix.go-version == '1.21' }}
36+
run: |
37+
make test-scripts

Makefile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
ROOT_DIR ?= $(shell git rev-parse --show-toplevel)
22
SCRIPTS_BASE ?= $(ROOT_DIR)/scripts
3+
GOLANG_CI_YAML_PATH ?= ${ROOT_DIR}/golang-ci.yaml
4+
GOLANG_CI_ARGS ?= --allow-parallel-runners --timeout=5m --config=${GOLANG_CI_YAML_PATH}
35

46
# SETUP AND TOOL INITIALIZATION TASKS
57
project-help:
@@ -13,6 +15,10 @@ lint-golangci-lint:
1315
@echo "Linting with golangci-lint"
1416
@$(SCRIPTS_BASE)/lint-golangci-lint.sh ${skip-non-generated-files}
1517

18+
lint-scripts:
19+
@echo "Linting scripts"
20+
@cd ${ROOT_DIR}/scripts && golangci-lint run ${GOLANG_CI_ARGS}
21+
1622
sync-tidy:
1723
@echo "Syncing and tidying dependencies"
1824
@$(SCRIPTS_BASE)/sync-tidy.sh
@@ -25,5 +31,26 @@ test-go:
2531
@echo "Running Go tests"
2632
@$(SCRIPTS_BASE)/test-go.sh ${skip-non-generated-files}
2733

34+
test-scripts:
35+
@echo "Running Go tests for scripts"
36+
@go test $(ROOT_DIR)/scripts/... ${GOTEST_ARGS}
37+
2838
test:
2939
@$(MAKE) --no-print-directory test-go skip-non-generated-files=${skip-non-generated-files}
40+
41+
# AUTOMATIC TAG
42+
sdk-tag-services:
43+
@if [ "${password}" = "" ]; then \
44+
go run $(SCRIPTS_BASE)/automatic_tag.go --update-type ${update-type} --ssh-private-key-file-path ${ssh-private-key-file-path}; \
45+
else \
46+
go run $(SCRIPTS_BASE)/automatic_tag.go --update-type ${update-type} --ssh-private-key-file-path ${ssh-private-key-file-path} --password ${password}; \
47+
fi
48+
49+
50+
sdk-tag-core:
51+
@if [ "${password}" = "" ]; then \
52+
go run $(SCRIPTS_BASE)/automatic_tag.go --update-type ${update-type} --ssh-private-key-file-path ${ssh-private-key-file-path} --target core; \
53+
else \
54+
go run $(SCRIPTS_BASE)/automatic_tag.go --update-type ${update-type} --ssh-private-key-file-path ${ssh-private-key-file-path} --target core --password ${password}; \
55+
fi
56+

go.work

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use (
2525
./examples/serviceaccount
2626
./examples/ske
2727
./examples/waiter
28+
./scripts
2829
./services/argus
2930
./services/authorization
3031
./services/dns

scripts/automatic_tag.go

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
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

Comments
 (0)