Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: release automation #198

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added chore/__debug_bin2080182539
Binary file not shown.
155 changes: 155 additions & 0 deletions chore/release/release.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package chore

import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"os/exec"
"strings"
"time"

"github.com/Masterminds/semver/v3"
"github.com/cli/go-gh/v2/pkg/api"
"github.com/go-git/go-git/v5"
"github.com/rs/zerolog/log"

copyright "github.com/coreruleset/crs-toolchain/v2/chore/update_copyright"
"github.com/coreruleset/crs-toolchain/v2/context"
)

const examplesPath = "util/crs-rules-check/examples"
const branchNameTemplate = "release/v%d.%d.%d"
const prTitleTemplate = "chore: release v%d.%d.%d"

var logger = log.With().Str("component", "release").Logger()

func Release(context *context.Context, repositoryPath string, version *semver.Version, sourceRef string) {
remoteName := findRemoteName()
if remoteName == "" {
logger.Fatal().Msg("failed to find remote for coreruleset/coreruleset")
}
fetchSourceRef(remoteName, sourceRef)
branchName := fmt.Sprintf(branchNameTemplate, version.Major(), version.Minor(), version.Patch())
createAndCheckOutBranch(context, branchName, sourceRef)
copyright.UpdateCopyright(context, version, uint16(time.Now().Year()), []string{examplesPath})
createCommit(context, branchName)
pushBranch(remoteName, branchName)
createPullRequest(version, branchName, sourceRef)
}

func createAndCheckOutBranch(context *context.Context, branchName string, sourceRef string) {
if err := checkForCleanWorkTree(context); err != nil {
// FIXME
panic(err)
}

out, err := runGit(context.RootDir(), "switch", "-c", branchName, sourceRef)
if err != nil {
logger.Fatal().Err(err).Bytes("command-output", out).Msg("failed to create commit for release")
}
}

func createCommit(context *context.Context, branchName string) {
out, err := runGit(context.RootDir(), "commit", "-am", "chore: release "+branchName)
if err != nil {
logger.Fatal().Err(err).Bytes("command-output", out).Msg("failed to create commit for release")
}
}

func checkForCleanWorkTree(context *context.Context) error {
repositoryPath := context.RootDir()
repo, err := git.PlainOpen(repositoryPath)
if err != nil {
//FIXME
panic(err)
}
worktree, err := repo.Worktree()
if err != nil {
//FIXME
panic(err)
}
status, err := worktree.Status()
if err != nil {
//FIXME
panic(err)
}
if !status.IsClean() {
// FIXME
return errors.New("worktree not clean. Please stash or commit your changes first")
}
return nil
}

func createPullRequest(version *semver.Version, branchName string, targetBranchName string) {
opts := api.ClientOptions{
Headers: map[string]string{"Accept": "application/octet-stream"},
}
client, err := api.NewRESTClient(opts)
if err != nil {
log.Fatal().Err(err).Send()
}

type prBody struct {
Title string `json:"title"`
Head string `json:"head"`
Base string `json:"base"`
Labels []string `json:"labels"`
Reviewer string `json:"reviewer"`
}
bodyJson, err := json.Marshal(&prBody{
Title: fmt.Sprintf(prTitleTemplate, version.Major(), version.Minor(), version.Patch()),
Head: "coreruleset:" + branchName,
Base: targetBranchName,
Labels: []string{"release", "release:ignore"},
Reviewer: "coreruleset/core-developers",
})
if err != nil {
log.Fatal().Err(err).Msg("failed to serialize body of GH REST request")
}

response, err := client.Request(http.MethodPost, "repos/coreruleset/coreruleset/pulls", bytes.NewReader(bodyJson))
if err != nil {
log.Fatal().Err(err).Msg("creating PR failed")
}
defer response.Body.Close()
}

func pushBranch(remoteName string, branchName string) {
out, err := runGit("push", remoteName, branchName)
if err != nil {
logger.Fatal().Err(err).Bytes("command-output", out)
}
}

func fetchSourceRef(remoteName string, sourceRef string) {
out, err := runGit("fetch", remoteName, sourceRef)
if err != nil {
logger.Fatal().Err(err).Bytes("command-output", out)
}
}

func runGit(repositoryPath string, args ...string) ([]byte, error) {
cmd := exec.Command("git", args...)
cmd.Dir = repositoryPath
return cmd.CombinedOutput()
}

func findRemoteName() string {
out, err := runGit("remote", "-v")
if err != nil {
logger.Fatal().Err(err).Bytes("command-output", out)
}
var remoteName string
scanner := bufio.NewScanner(bytes.NewReader(out))
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "coreruleset/coreruleset") {
remoteName = strings.Split(line, " ")[0]
}
}

return remoteName
}
106 changes: 106 additions & 0 deletions chore/release/release_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package chore

import (
"fmt"
"os"
"os/exec"
"path"
"testing"

"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/stretchr/testify/suite"

"github.com/coreruleset/crs-toolchain/v2/context"
)

type choreReleaseTestSuite struct {
suite.Suite
repoDir string
}

func (s *choreReleaseTestSuite) SetupTest() {
s.repoDir = s.T().TempDir()

out, err := runGit(s.repoDir, "init", "-b", "main")
s.Require().NoError(err, string(out))

out, err = runGit(s.repoDir, "config", "user.email", "[email protected]")
s.Require().NoError(err, string(out))

out, err = runGit(s.repoDir, "config", "user.name", "dummy")
s.Require().NoError(err, string(out))

out, err = runGit(s.repoDir, "commit", "--allow-empty", "-m", "dummy")
s.Require().NoError(err, string(out))
}

func TestRunChoreReleaseTestSuite(t *testing.T) {
suite.Run(t, new(choreReleaseTestSuite))
}

func (s *choreReleaseTestSuite) TestCreateAndcheckoutBranch() {
branchName := "v1.2.3"
ctxt := context.New(s.repoDir, "")
createAndCheckOutBranch(ctxt, branchName, "main")
repo, err := git.PlainOpen(s.repoDir)
s.Require().NoError(err)

branches, err := repo.Branches()
s.Require().NoError(err)

found := false
var branchRef *plumbing.Reference
err = branches.ForEach(func(r *plumbing.Reference) error {
if r.Name().Short() == branchName {
found = true
branchRef = r
}
return nil
})
s.Require().NoError(err)
s.True(found)

headRef, err := repo.Head()
s.Require().NoError(err)

s.Equal(branchRef.Hash(), headRef.Hash(), "New branch should have been checked out")
}

func (s *choreReleaseTestSuite) TestCreateCommit() {
branchName := "v1.2.3"
ctxt := context.New(s.repoDir, "")
createAndCheckOutBranch(ctxt, branchName, "main")

// Add something to commit, as `createCommit` doesn't allow empty commits
err := os.WriteFile(path.Join(s.repoDir, "file"), []byte("content"), os.ModePerm)
s.Require().NoError(err)
cmd := exec.Command("git", "add", ".")
cmd.Dir = s.repoDir
err = cmd.Run()
s.Require().NoError(err)

createCommit(ctxt, branchName)

repo, err := git.PlainOpen(s.repoDir)
s.Require().NoError(err)
worktree, err := repo.Worktree()
s.Require().NoError(err)
status, err := worktree.Status()
s.Require().NoError(err)
s.True(status.IsClean())

// HEAD has the new commit message
revision, err := repo.ResolveRevision("HEAD")
s.Require().NoError(err)
commit, err := repo.CommitObject(*revision)
s.Require().NoError(err)
s.Equal(fmt.Sprintf("chore: release %s\n", branchName), commit.Message)

// parent of HEAD is main
parent, err := commit.Parent(0)
s.Require().NoError(err)
branchHash, err := repo.ResolveRevision(plumbing.Revision("main"))
s.Require().NoError(err)
s.Equal(*branchHash, parent.Hash)
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"regexp"
"strings"

"github.com/Masterminds/semver/v3"
"github.com/rs/zerolog/log"

"github.com/coreruleset/crs-toolchain/v2/context"
Expand All @@ -19,7 +20,7 @@ import (
var logger = log.With().Str("component", "update-copyright").Logger()

// UpdateCopyright updates the copyright portion of the rules files to the provided year and version.
func UpdateCopyright(ctxt *context.Context, version string, year string) {
func UpdateCopyright(ctxt *context.Context, version *semver.Version, year uint16, ignoredPaths []string) {
err := filepath.WalkDir(ctxt.RootDir(), func(path string, d fs.DirEntry, err error) error {
if err != nil {
// abort
Expand All @@ -29,6 +30,13 @@ func UpdateCopyright(ctxt *context.Context, version string, year string) {
// continue
return nil
}
for _, ignoredPath := range ignoredPaths {
if strings.HasPrefix(path, ignoredPath) {
// continue
return nil
}
}

if strings.HasSuffix(d.Name(), ".conf") || strings.HasSuffix(d.Name(), ".example") {
if err := processFile(path, version, year); err != nil {
// abort
Expand All @@ -43,7 +51,7 @@ func UpdateCopyright(ctxt *context.Context, version string, year string) {
}
}

func processFile(filePath string, version string, year string) error {
func processFile(filePath string, version *semver.Version, year uint16) error {
logger.Info().Msgf("Processing %s", filePath)

contents, err := os.ReadFile(filePath)
Expand All @@ -63,16 +71,16 @@ func processFile(filePath string, version string, year string) error {

// Ideally we have support in the future for a proper parser file, so we can use that to change it
// in a more elegant way. Right now we just match strings.
func updateRules(version string, year string, contents []byte) ([]byte, error) {
func updateRules(version *semver.Version, year uint16, contents []byte) ([]byte, error) {
scanner := bufio.NewScanner(bytes.NewReader(contents))
scanner.Split(bufio.ScanLines)
output := new(bytes.Buffer)
writer := bufio.NewWriter(output)
replaceVersion := fmt.Sprintf("${1}%s", version)
// only keep numbers from the version
onlyNumbersVersion := strings.Join(regexp.MustCompile(`\d+`).FindAllString(version, -1), "")
onlyNumbersVersion := strings.Join(regexp.MustCompile(`\d+`).FindAllString(version.String(), -1), "")
replaceShortVersion := fmt.Sprintf("${1}%s", onlyNumbersVersion)
replaceYear := fmt.Sprintf("${1}%s${3}", year)
replaceYear := fmt.Sprintf("${1}%d${3}", year)
replaceSecRuleVersion := fmt.Sprintf("${1}%s", version)
replaceSecComponentSignature := fmt.Sprintf("${1}%s", version)
for scanner.Scan() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ package chore
import (
"testing"

"github.com/Masterminds/semver/v3"
"github.com/stretchr/testify/suite"
)

type copyrightUpdateTestsTestSuite struct {
suite.Suite
}

func (s *copyrightUpdateTestsTestSuite) SetupTest() {
}

func TestRunUpdateCopyrightTestsTestSuite(t *testing.T) {
suite.Run(t, new(copyrightUpdateTestsTestSuite))
}
Expand Down Expand Up @@ -56,7 +54,9 @@ SecComponentSignature "OWASP_CRS/4.0.0-rc1"
#
SecComponentSignature "OWASP_CRS/9.1.22"
`
out, err := updateRules("9.1.22", "2042", []byte(contents))
version, err := semver.NewVersion("9.1.22")
s.Require().NoError(err)
out, err := updateRules(version, 2042, []byte(contents))
s.Require().NoError(err)

s.Equal(expected, string(out))
Expand Down Expand Up @@ -142,7 +142,9 @@ SecRule &TX:crs_setup_version "@eq 0" \
#
SecComponentSignature "OWASP_CRS/4.99.12"
`
out, err := updateRules("4.99.12", "2041", []byte(contents))
version, err := semver.NewVersion("4.99.12")
s.Require().NoError(err)
out, err := updateRules(version, 2041, []byte(contents))
s.Require().NoError(err)

s.Equal(expected, string(out))
Expand All @@ -155,7 +157,9 @@ func (s *copyrightUpdateTestsTestSuite) TestUpdateCopyrightTests_AddsNewLine() {
expected := `# ------------------------------------------------------------------------
# OWASP ModSecurity Core Rule Set ver.4.99.12
`
out, err := updateRules("4.99.12", "2041", []byte(contents))
version, err := semver.NewVersion("4.99.12")
s.Require().NoError(err)
out, err := updateRules(version, 2041, []byte(contents))
s.Require().NoError(err)

s.Equal(expected, string(out))
Expand All @@ -169,7 +173,9 @@ func (s *copyrightUpdateTestsTestSuite) TestUpdateCopyrightTests_SupportsRelease
expected := `# ------------------------------------------------------------------------
# OWASP ModSecurity Core Rule Set ver.4.1.0-rc1
`
out, err := updateRules("4.1.0-rc1", "2041", []byte(contents))
version, err := semver.NewVersion("4.1.0-rc1")
s.Require().NoError(err)
out, err := updateRules(version, 2041, []byte(contents))
s.Require().NoError(err)

s.Equal(expected, string(out))
Expand Down
Loading
Loading