diff --git a/.github/actions/gh-release/.gitattributes b/.github/actions/gh-release/.gitattributes new file mode 100644 index 0000000..2e051e1 --- /dev/null +++ b/.github/actions/gh-release/.gitattributes @@ -0,0 +1 @@ +dist/** -diff linguist-generated=true \ No newline at end of file diff --git a/.github/actions/gh-release/.gitignore b/.github/actions/gh-release/.gitignore new file mode 100644 index 0000000..b80cfa7 --- /dev/null +++ b/.github/actions/gh-release/.gitignore @@ -0,0 +1,101 @@ +# Dependency directory +node_modules + +# Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# OS metadata +.DS_Store +Thumbs.db + +# Ignore built ts files +__tests__/runner/* +lib/**/* + +gh-release diff --git a/.github/actions/gh-release/Dockerfile b/.github/actions/gh-release/Dockerfile new file mode 100644 index 0000000..c0c6d5d --- /dev/null +++ b/.github/actions/gh-release/Dockerfile @@ -0,0 +1,11 @@ +FROM golang:1.20.5-alpine3.18 + +RUN apk update && apk add git + +COPY . /app + +RUN cd /app && \ + go build -o /gh-release . && \ + chmod +x /gh-release + +ENTRYPOINT ["/gh-release"] diff --git a/.github/actions/gh-release/Makefile b/.github/actions/gh-release/Makefile new file mode 100644 index 0000000..68750ab --- /dev/null +++ b/.github/actions/gh-release/Makefile @@ -0,0 +1,11 @@ +.PHONY: build +build: + go build -o gh-release . + +.PHONY: test +test: + go test ./... + +.PHONY: dep +dep: + go mod tidy diff --git a/.github/actions/gh-release/action.yaml b/.github/actions/gh-release/action.yaml new file mode 100644 index 0000000..805e5fd --- /dev/null +++ b/.github/actions/gh-release/action.yaml @@ -0,0 +1,29 @@ +name: 'Create GitHub Release' +description: 'Create new releases when any specified RELEASE files were modified.' +author: 'PipeCD team' +branding: + icon: 'tag' + color: 'green' + +inputs: + release_file: + description: 'The path to the RELEASE file or pattern to match one or multiple RELEASE files.' + required: true + token: + description: 'The GITHUB_TOKEN secret.' + required: true + output_releases_file_path: + description: 'The path to output the list of releases formatted in JSON.' + required: false + +outputs: + releases: + description: 'The list of releases formatted in JSON.' + +runs: + using: 'docker' + image: 'Dockerfile' + args: + - release-file=${{ inputs.release_file }} + - token=${{ inputs.token }} + - output-releases-file-path=${{ inputs.output_releases_file_path }} diff --git a/.github/actions/gh-release/comment.go b/.github/actions/gh-release/comment.go new file mode 100644 index 0000000..027b948 --- /dev/null +++ b/.github/actions/gh-release/comment.go @@ -0,0 +1,61 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "fmt" + "strings" +) + +const ( + successBadgeURL = ` +[![RELEASE](https://img.shields.io/static/v1?label=GitHub&message=RELEASE&color=success&style=flat)](https://github.com/pipe-cd/actions-gh-release) + +` +) + +func makeCommentBody(proposals []ReleaseProposal, exists []ReleaseProposal) string { + var b strings.Builder + b.WriteString(successBadgeURL) + + if len(proposals) == 0 { + if len(exists) == 0 { + fmt.Fprintf(&b, "No GitHub releases will be created one this pull request got merged. Because this pull request did not modified any RELEASE files.\n") + return b.String() + } + + fmt.Fprintf(&b, "No GitHub releases will be created one this pull request got merged. Because the following tags were already created before.\n") + for _, p := range exists { + fmt.Fprintf(&b, "- %s\n", p.Tag) + } + return b.String() + } + + b.WriteString(fmt.Sprintf("The following %d GitHub releases will be created once this pull request got merged.\n", len(proposals))) + for _, p := range proposals { + fmt.Fprintf(&b, "\n") + fmt.Fprintf(&b, p.ReleaseNote) + fmt.Fprintf(&b, "\n") + } + + if len(exists) > 0 { + fmt.Fprintf(&b, "The following %d releases will be skipped because they were already created before.\n", len(exists)) + for _, p := range exists { + fmt.Fprintf(&b, "- %s\n", p.Tag) + } + } + + return b.String() +} diff --git a/.github/actions/gh-release/comment_test.go b/.github/actions/gh-release/comment_test.go new file mode 100644 index 0000000..bfa99c4 --- /dev/null +++ b/.github/actions/gh-release/comment_test.go @@ -0,0 +1,67 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMakeCommentBody(t *testing.T) { + testcases := []struct { + name string + proposals []ReleaseProposal + exists []ReleaseProposal + expected string + }{ + { + name: "no release", + expected: "testdata/no-release-comment.txt", + }, + { + name: "one release", + proposals: []ReleaseProposal{ + ReleaseProposal{ + ReleaseNote: "Release note for tag 1", + }, + }, + expected: "testdata/one-release-comment.txt", + }, + { + name: "multiple releases", + proposals: []ReleaseProposal{ + ReleaseProposal{ + ReleaseNote: "Release note for tag 1", + }, + ReleaseProposal{ + ReleaseNote: "Release note for tag 2", + }, + }, + expected: "testdata/multi-release-comment.txt", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := makeCommentBody(tc.proposals, tc.exists) + expected, err := testdata.ReadFile(tc.expected) + require.NoError(t, err) + + assert.Equal(t, string(expected), got) + }) + } +} diff --git a/.github/actions/gh-release/filematcher.go b/.github/actions/gh-release/filematcher.go new file mode 100644 index 0000000..63d8951 --- /dev/null +++ b/.github/actions/gh-release/filematcher.go @@ -0,0 +1,255 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Copyright 2013-2018 Docker, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "strings" + "text/scanner" +) + +type RegexpProvider func(string) (*regexp.Regexp, error) + +// PatternMatcher allows checking paths against a list of patterns. +type PatternMatcher struct { + patterns []*Pattern + exclusions []*Pattern + regexpProvider RegexpProvider +} + +// An Option configures a PatternMatcher. +type Option func(*PatternMatcher) + +// WithRegexpProvider sets a custom regexp provider. +func WithRegexpProvider(p RegexpProvider) Option { + return func(pm *PatternMatcher) { + pm.regexpProvider = p + } +} + +// NewPatternMatcher creates a new matcher object for specific patterns that can +// be used later to match against patterns against paths. +func NewPatternMatcher(patterns []string, opts ...Option) (*PatternMatcher, error) { + pm := &PatternMatcher{ + regexpProvider: regexp.Compile, + } + for _, opt := range opts { + opt(pm) + } + for _, p := range patterns { + // Eliminate leading and trailing whitespace. + p = strings.TrimSpace(p) + if p == "" { + continue + } + p = filepath.Clean(p) + negative := false + if p[0] == '!' { + if len(p) == 1 { + return nil, errors.New("illegal exclusion pattern: \"!\"") + } + p = p[1:] + negative = true + } + // Do some syntax checking on the pattern. + // filepath's Match() has some really weird rules that are inconsistent + // so instead of trying to dup their logic, just call Match() for its + // error state and if there is an error in the pattern return it. + // If this becomes an issue we can remove this since its really only + // needed in the error (syntax) case - which isn't really critical. + if _, err := filepath.Match(p, "."); err != nil { + return nil, err + } + newp := &Pattern{ + cleanedPattern: p, + dirs: strings.Split(p, string(os.PathSeparator)), + } + regexp, err := pm.regexpProvider(newp.regexpString()) + if err != nil { + return nil, filepath.ErrBadPattern + } + newp.regexp = regexp + if negative { + pm.exclusions = append(pm.exclusions, newp) + } else { + pm.patterns = append(pm.patterns, newp) + } + } + return pm, nil +} + +// Matches matches path against all the patterns. Matches is not safe to be +// called concurrently. +func (pm *PatternMatcher) Matches(file string) bool { + matched := matches(file, pm.exclusions) + if matched { + return false + } + return matches(file, pm.patterns) +} + +func (pm *PatternMatcher) MatchesAny(files []string) bool { + for _, file := range files { + if pm.Matches(file) { + return true + } + } + return false +} + +func matches(file string, patterns []*Pattern) bool { + file = filepath.FromSlash(file) + parentPath := filepath.Dir(file) + parentPathDirs := strings.Split(parentPath, string(os.PathSeparator)) + + for _, pattern := range patterns { + matched := pattern.regexp.MatchString(file) + if !matched && parentPath != "." { + // Check to see if the pattern matches one of our parent dirs. + if len(pattern.dirs) <= len(parentPathDirs) { + matched = pattern.regexp.MatchString(strings.Join(parentPathDirs[:len(pattern.dirs)], string(os.PathSeparator))) + } + } + if matched { + return true + } + } + return false +} + +// Exclusions returns array of negative patterns. +func (pm *PatternMatcher) Exclusions() []*Pattern { + return pm.exclusions +} + +// Patterns returns array of active patterns. +func (pm *PatternMatcher) Patterns() []*Pattern { + return pm.patterns +} + +// Pattern defines a single regexp used to filter file paths. +type Pattern struct { + cleanedPattern string + dirs []string + regexp *regexp.Regexp +} + +func (p *Pattern) String() string { + return p.cleanedPattern +} + +func (p *Pattern) regexpString() string { + regStr := "^" + pattern := p.cleanedPattern + // Go through the pattern and convert it to a regexp. + // We use a scanner so we can support utf-8 chars. + var scan scanner.Scanner + scan.Init(strings.NewReader(pattern)) + + sl := string(os.PathSeparator) + escSL := sl + if sl == `\` { + escSL += `\` + } + + for scan.Peek() != scanner.EOF { + ch := scan.Next() + + if ch == '*' { + if scan.Peek() == '*' { + // Is some flavor of "**". + scan.Next() + + // Treat **/ as ** so eat the "/". + if string(scan.Peek()) == sl { + scan.Next() + } + + if scan.Peek() == scanner.EOF { + // Is "**EOF" - to align with .gitignore just accept all. + regStr += ".*" + } else { + // Is "**". + // Note that this allows for any # of /'s (even 0) because + // the .* will eat everything, even /'s. + regStr += "(.*" + escSL + ")?" + } + } else { + // Is "*" so map it to anything but "/". + regStr += "[^" + escSL + "]*" + } + } else if ch == '?' { + // "?" is any char except "/". + regStr += "[^" + escSL + "]" + } else if ch == '.' || ch == '$' { + // Escape some regexp special chars that have no meaning + // in golang's filepath.Match. + regStr += `\` + string(ch) + } else if ch == '\\' { + // Escape next char. Note that a trailing \ in the pattern + // will be left alone (but need to escape it). + if sl == `\` { + // On windows map "\" to "\\", meaning an escaped backslash, + // and then just continue because filepath.Match on + // Windows doesn't allow escaping at all. + regStr += escSL + continue + } + if scan.Peek() != scanner.EOF { + regStr += `\` + string(scan.Next()) + } else { + regStr += `\` + } + } else { + regStr += string(ch) + } + } + regStr += "$" + return regStr +} + +// Matches returns true if file matches any of the patterns +// and isn't excluded by any of the subsequent patterns. +func Matches(file string, patterns []string, opts ...Option) (bool, error) { + pm, err := NewPatternMatcher(patterns, opts...) + if err != nil { + return false, err + } + file = filepath.Clean(file) + + if file == "." { + // Don't let them exclude everything, kind of silly. + return false, nil + } + + return pm.Matches(file), nil +} diff --git a/.github/actions/gh-release/git.go b/.github/actions/gh-release/git.go new file mode 100644 index 0000000..9020a67 --- /dev/null +++ b/.github/actions/gh-release/git.go @@ -0,0 +1,191 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "bytes" + "context" + "fmt" + "os/exec" + "strconv" + "strings" +) + +const ( + separator = "__GIT_LOG_SEPARATOR__" + delimiter = "__GIT_LOG_DELIMITER__" + fieldNum = 9 + commitLogFormat = separator + + "%an" + delimiter + // author name + "%cn" + delimiter + // committer name + "%at" + delimiter + // author date (UNIX timestamp) + "%H" + delimiter + // commit hash + "%h" + delimiter + // abbreviated commit hash + "%P" + delimiter + // parent hashes + "%p" + delimiter + // abbreviated parent hashes + "%s" + delimiter + // subject + "%b" // body +) + +type Commit struct { + Author string `json:"author,omitempty"` + Committer string `json:"committer,omitempty"` + CreatedAt int `json:"createdAt,omitempty"` + Hash string `json:"hash,omitempty"` + AbbreviatedHash string `json:"abbreviatedHash,omitempty"` + ParentHashes []string `json:"parentHashes,omitempty"` + AbbreviatedParentHashes []string `json:"abbreviatedParentHashes,omitempty"` + Subject string `json:"subject,omitempty"` + Body string `json:"body,omitempty"` +} + +func (c Commit) IsMerge() bool { + return len(c.ParentHashes) == 2 +} + +func (c Commit) PullRequestNumber() (int, bool) { + if !c.IsMerge() { + return 0, false + } + + subs := defaultMergeCommitRegex.FindStringSubmatch(c.Subject) + if len(subs) != 2 { + return 0, false + } + + prNumber, err := strconv.Atoi(subs[1]) + if err != nil { + return 0, false + } + + return prNumber, true +} + +func parseCommits(log string) ([]Commit, error) { + lines := strings.Split(log, separator) + if len(lines) < 1 { + return nil, fmt.Errorf("invalid log") + } + commits := make([]Commit, 0, len(lines)) + for _, line := range lines[1:] { + commit, err := parseCommit(line) + if err != nil { + return nil, err + } + commits = append(commits, commit) + } + return commits, nil +} + +func parseCommit(log string) (Commit, error) { + fields := strings.Split(log, delimiter) + if len(fields) != fieldNum { + return Commit{}, fmt.Errorf("invalid log: log line should contain %d fields but got %d", fieldNum, len(fields)) + } + createdAt, err := strconv.Atoi(fields[2]) + if err != nil { + return Commit{}, err + } + return Commit{ + Author: fields[0], + Committer: fields[1], + CreatedAt: createdAt, + Hash: fields[3], + AbbreviatedHash: fields[4], + ParentHashes: strings.Split(fields[5], " "), + AbbreviatedParentHashes: strings.Split(fields[6], " "), + Subject: fields[7], + Body: strings.TrimSpace(fields[8]), + }, nil +} + +// listCommits returns a list of commits between the given revision range. +func listCommits(ctx context.Context, gitExecPath, repoDir string, revisionRange string) ([]Commit, error) { + args := []string{ + "log", + "--no-decorate", + fmt.Sprintf("--pretty=format:%s", commitLogFormat), + } + if revisionRange != "" { + args = append(args, revisionRange) + } + + cmd := exec.CommandContext(ctx, gitExecPath, args...) + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("err: %w, out: %s", err, string(out)) + } + + return parseCommits(string(out)) +} + +// changedFiles returns a list of files those were touched between two commits. +func changedFiles(ctx context.Context, gitExecPath, repoDir, from, to string) ([]string, error) { + cmd := exec.CommandContext(ctx, gitExecPath, "diff", "--name-only", from, to) + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + + if err != nil { + return nil, fmt.Errorf("err: %w, out: %s", err, string(out)) + } + + var ( + lines = strings.Split(string(out), "\n") + files = make([]string, 0, len(lines)) + ) + // We need to remove all empty lines since the result may include them. + for _, f := range lines { + if f != "" { + files = append(files, f) + } + } + return files, nil +} + +// readFileAtCommit reads the content of a specific file at the given commit. +func readFileAtCommit(ctx context.Context, gitExecPath, repoDir, filePath, commit string) ([]byte, error) { + args := []string{ + "show", + fmt.Sprintf("%s:%s", commit, filePath), + } + + cmd := exec.CommandContext(ctx, gitExecPath, args...) + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("err: %w, out: %s", err, string(out)) + } + + return bytes.TrimSpace(out), nil +} + +func addSafeDirectory(ctx context.Context, gitExecPath, repoDir string) error { + args := []string{ + "config", + "--global", + "--add", + "safe.directory", + repoDir, + } + + cmd := exec.CommandContext(ctx, gitExecPath, args...) + cmd.Dir = repoDir + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("err: %w, out: %s", err, string(out)) + } + return nil +} diff --git a/.github/actions/gh-release/git_test.go b/.github/actions/gh-release/git_test.go new file mode 100644 index 0000000..b101f65 --- /dev/null +++ b/.github/actions/gh-release/git_test.go @@ -0,0 +1,86 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCommit(t *testing.T) { + log, err := testdata.ReadFile("testdata/log.txt") + require.NoError(t, err) + require.NotNil(t, log) + + expected := []Commit{ + { + Author: "nghialv", + Committer: "kapetanios-robot", + CreatedAt: 1565752022, + Hash: "74e20ede0242fdc7fd75b5be56e8d7fa72060707", + AbbreviatedHash: "74e20ed", + ParentHashes: []string{"ea8674c36467fc4d5a2e7900fa47e6c0d7b40948"}, + AbbreviatedParentHashes: []string{"ea8674c"}, + Subject: "wip", + }, + { + Author: "Le Van Nghia", + Committer: "kapetanios-robot", + CreatedAt: 1565749682, + Hash: "c9a7596e7e92ea5e3f03eeb951f632acb02b88a3", + AbbreviatedHash: "c9a7596", + ParentHashes: []string{"74e20ede0242fdc7fd75b5be56e8d7fa72060707"}, + AbbreviatedParentHashes: []string{"74e20ed"}, + Subject: `Add implementation of inplug service (#648)`, + Body: `**What this PR does / why we need it**: + +**Which issue(s) this PR fixes**: + +Fixes # + +**Does this PR introduce a user-facing change?**: + +` + "```" + `release-note +NONE +` + "```" + ` + +This PR was merged by Kapetanios.`, + }, + { + Author: "nghialv", + Committer: "kapetanios-robot", + CreatedAt: 2565752022, + Hash: "24e20ede0242fdc7fd75b5be56e8d7fa72060707", + AbbreviatedHash: "24e20ed", + ParentHashes: []string{"74e20ede0242fdc7fd75b5be56e8d7fa72060707", "c9a7596e7e92ea5e3f03eeb951f632acb02b88a3"}, + AbbreviatedParentHashes: []string{"74e20ed", "c9a7596"}, + Subject: `Added commands to "kapectl" for creating, updating project secret (#475)`, + }, + } + commits, err := parseCommits(string(log)) + require.NoError(t, err) + sort.Slice(expected, func(i, j int) bool { + return expected[i].Hash > expected[j].Hash + }) + sort.Slice(commits, func(i, j int) bool { + return commits[i].Hash > commits[j].Hash + }) + assert.Equal(t, expected, commits) +} diff --git a/.github/actions/gh-release/github.go b/.github/actions/gh-release/github.go new file mode 100644 index 0000000..50ec0f9 --- /dev/null +++ b/.github/actions/gh-release/github.go @@ -0,0 +1,252 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "os" + "net/http" + "regexp" + + "github.com/google/go-github/v39/github" + "golang.org/x/oauth2" +) + +var ( + defaultMergeCommitRegex = regexp.MustCompile(`Merge pull request #([0-9]+) from .+`) +) + +type ( + PullRequestState string + PullRequestSort string + PullRequestDirection string +) + +const ( + // PullRequestState + PullRequestStateOpen PullRequestState = "open" + PullRequestStateClosed PullRequestState = "closed" + PullRequestStateAll PullRequestState = "all" + + // PullRequestSort + PullRequestSortCreated PullRequestSort = "created" + PullRequestSortUpdated PullRequestSort = "updated" + PullRequestSortPopularity PullRequestSort = "popularity" + PullRequestSortLongRunning PullRequestSort = "long-running" + + // PullRequestDirection + PullRequestDirectionAsc PullRequestDirection = "asc" + PullRequestDirectionDesc PullRequestDirection = "desc" +) + +type githubEvent struct { + Name string + + Owner string + Repo string + + HeadCommit string + BaseCommit string + + PRNumber int + IsComment bool + CommentURL string +} + +const ( + eventPush = "push" + eventPullRequest = "pull_request" + eventIssueComment = "issue_comment" +) + +type githubClient struct { + restClient *github.Client +} + +type githubClientConfig struct { + Token string +} + +func (p PullRequestState) String() string { + return string(p) +} + +func (p PullRequestSort) String() string { + return string(p) +} + +func (p PullRequestDirection) String() string { + return string(p) +} + +func newGitHubClient(ctx context.Context, cfg *githubClientConfig) *githubClient { + var httpClient *http.Client + if cfg.Token != "" { + t := &oauth2.Token{AccessToken: cfg.Token} + ts := oauth2.StaticTokenSource(t) + httpClient = oauth2.NewClient(ctx, ts) + } + + return &githubClient{ + restClient: github.NewClient(httpClient), + } +} + +// parsePullRequestEvent uses the given environment variables +// to parse and build githubEvent struct. +func (g *githubClient) parseGitHubEvent(ctx context.Context) (*githubEvent, error) { + var parseEvents = map[string]struct{}{ + eventPush: {}, + eventPullRequest: {}, + eventIssueComment: {}, + } + + eventName := os.Getenv("GITHUB_EVENT_NAME") + if _, ok := parseEvents[eventName]; !ok { + return &githubEvent{ + Name: eventName, + }, nil + } + + eventPath := os.Getenv("GITHUB_EVENT_PATH") + payload, err := os.ReadFile(eventPath) + if err != nil { + return nil, fmt.Errorf("failed to read event payload: %v", err) + } + + event, err := github.ParseWebHook(eventName, payload) + if err != nil { + return nil, fmt.Errorf("failed to parse event payload: %v", err) + } + + switch e := event.(type) { + case *github.PushEvent: + return &githubEvent{ + Name: eventPush, + Owner: e.Repo.Owner.GetLogin(), + Repo: e.Repo.GetName(), + HeadCommit: e.GetAfter(), + BaseCommit: e.GetBefore(), + }, nil + + case *github.PullRequestEvent: + return &githubEvent{ + Name: eventPullRequest, + Owner: e.Repo.Owner.GetLogin(), + Repo: e.Repo.GetName(), + HeadCommit: e.PullRequest.Head.GetSHA(), + BaseCommit: e.PullRequest.Base.GetSHA(), + PRNumber: e.GetNumber(), + }, nil + + case *github.IssueCommentEvent: + var ( + owner = e.Repo.Owner.GetLogin() + repo = e.Repo.GetName() + prNum = e.Issue.GetNumber() + ) + + pr, _, err := g.restClient.PullRequests.Get(ctx, owner, repo, prNum) + if err != nil { + return nil, err + } + + return &githubEvent{ + Name: eventIssueComment, + Owner: owner, + Repo: repo, + HeadCommit: pr.Head.GetSHA(), + BaseCommit: pr.Base.GetSHA(), + PRNumber: prNum, + IsComment: true, + CommentURL: e.Comment.GetHTMLURL(), + }, nil + + default: + return nil, fmt.Errorf("got an unexpected event type, got: %t", e) + } +} + +func (g *githubClient) sendComment(ctx context.Context, owner, repo string, prNum int, body string) (*github.IssueComment, error) { + c, _, err := g.restClient.Issues.CreateComment(ctx, owner, repo, prNum, &github.IssueComment{ + Body: &body, + }) + return c, err +} + +func (g *githubClient) createRelease(ctx context.Context, owner, repo string, p ReleaseProposal) (*github.RepositoryRelease, error) { + release, _, err := g.restClient.Repositories.CreateRelease(ctx, owner, repo, &github.RepositoryRelease{ + TagName: &p.Tag, + Name: &p.Title, + TargetCommitish: &p.TargetCommitish, + Body: &p.ReleaseNote, + Prerelease: &p.Prerelease, + }) + return release, err +} + +func (g *githubClient) existRelease(ctx context.Context, owner, repo, tag string) (bool, error) { + _, resp, err := g.restClient.Repositories.GetReleaseByTag(ctx, owner, repo, tag) + if err != nil { + if resp != nil && resp.StatusCode == http.StatusNotFound { + return false, nil + } + return false, err + } + return resp.StatusCode == http.StatusOK, nil +} + +func (g *githubClient) getPullRequest(ctx context.Context, owner, repo string, number int) (*github.PullRequest, error) { + pr, _, err := g.restClient.PullRequests.Get(ctx, owner, repo, number) + return pr, err +} + +type ListPullRequestOptions struct { + State PullRequestState + Sort PullRequestSort + Direction PullRequestDirection + Limit int +} + +func (g *githubClient) listPullRequests(ctx context.Context, owner, repo string, opt *ListPullRequestOptions) ([]*github.PullRequest, error) { + const perPage = 100 + listOpts := github.ListOptions{PerPage: perPage} + opts := &github.PullRequestListOptions{ + State: opt.State.String(), + Sort: opt.Sort.String(), + Direction: opt.Direction.String(), + ListOptions: listOpts, + } + ret := make([]*github.PullRequest, 0, opt.Limit) + count := opt.Limit / perPage + for i := 0; i <= count; i++ { + prs, resp, err := g.restClient.PullRequests.List(ctx, owner, repo, opts) + if err != nil { + return nil, err + } + for _, pr := range prs { + if len(ret) == opt.Limit { + break + } + ret = append(ret, pr) + } + if resp.NextPage == 0 || len(ret) == opt.Limit { + break + } + opts.Page = resp.NextPage + } + return ret, nil +} diff --git a/.github/actions/gh-release/go.mod b/.github/actions/gh-release/go.mod new file mode 100644 index 0000000..a4d96e9 --- /dev/null +++ b/.github/actions/gh-release/go.mod @@ -0,0 +1,23 @@ +module github.com/pipe-cd/actions-gh-release + +go 1.22 + +require ( + github.com/creasty/defaults v1.5.2 + github.com/google/go-github/v39 v39.2.0 + github.com/stretchr/testify v1.4.0 + golang.org/x/oauth2 v0.5.0 + sigs.k8s.io/yaml v1.3.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/crypto v0.17.0 // indirect + golang.org/x/net v0.17.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/.github/actions/gh-release/go.sum b/.github/actions/gh-release/go.sum new file mode 100644 index 0000000..6e4ec53 --- /dev/null +++ b/.github/actions/gh-release/go.sum @@ -0,0 +1,57 @@ +github.com/creasty/defaults v1.5.2 h1:/VfB6uxpyp6h0fr7SPp7n8WJBoV8jfxQXPCnkVSjyls= +github.com/creasty/defaults v1.5.2/go.mod h1:FPZ+Y0WNrbqOVw+c6av63eyHUAl6pMHZwqLPvXUZGfY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= +github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= +github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.5.0 h1:HuArIo48skDwlrvM3sEdHXElYslAMsf3KwRkkW4MC4s= +golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/.github/actions/gh-release/main.go b/.github/actions/gh-release/main.go new file mode 100644 index 0000000..f80d6cc --- /dev/null +++ b/.github/actions/gh-release/main.go @@ -0,0 +1,200 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/google/go-github/v39/github" +) + +const ( + gitExecPath = "git" + defaultReleaseFile = "RELEASE" +) + +func main() { + log.Println("Start running actions-gh-release") + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + args, err := parseArgs(os.Args) + if err != nil { + log.Fatalf("Failed to parse arguments: %v\n", err) + } + log.Println("Successfully parsed arguments") + + workspace := os.Getenv("GITHUB_WORKSPACE") + if workspace == "" { + log.Fatal("GITHUB_WORKSPACE was not defined") + } + + if err := addSafeDirectory(ctx, gitExecPath, workspace); err != nil { + log.Fatalf("Failed to add %s as a safe directory: %v\n", workspace, err) + } + + cfg := &githubClientConfig{Token: args.Token} + ghClient := newGitHubClient(ctx, cfg) + + event, err := ghClient.parseGitHubEvent(ctx) + if err != nil { + log.Fatalf("Failed to parse GitHub event: %v\n", err) + } + log.Printf("Successfully parsed GitHub event %s\n\tbase-commit %s\n\thead-commit %s\n", + event.Name, + event.BaseCommit, + event.HeadCommit, + ) + + // Find all changed RELEASE files. + changedFiles, err := changedFiles(ctx, gitExecPath, workspace, event.BaseCommit, event.HeadCommit) + if err != nil { + log.Fatalf("Failed to list changed files: %v\n", err) + } + + changedReleaseFiles := make([]string, 0, 0) + matcher, err := NewPatternMatcher([]string{args.ReleaseFile}) + if err != nil { + log.Fatalf("Failed to create pattern matcher for release file: %v\n", err) + } + for _, f := range changedFiles { + if matcher.Matches(f) { + changedReleaseFiles = append(changedReleaseFiles, f) + } + } + + if len(changedReleaseFiles) == 0 { + log.Println("Nothing to do since there were no modified release files") + return + } + + proposals := make([]ReleaseProposal, 0, len(changedReleaseFiles)) + for _, f := range changedReleaseFiles { + p, err := buildReleaseProposal(ctx, ghClient, f, gitExecPath, workspace, event) + if err != nil { + log.Fatalf("Failed to build release for %s: %v\n", f, err) + } + proposals = append(proposals, *p) + } + + // Filter all existing releases. + var ( + newProposals = make([]ReleaseProposal, 0, len(proposals)) + existingProposals = make([]ReleaseProposal, 0) + ) + for _, p := range proposals { + if p.Tag != "" { + exist, err := ghClient.existRelease(ctx, event.Owner, event.Repo, p.Tag) + if err != nil { + log.Fatalf("Failed to check the existence of release for %s: %v\n", p.Tag, err) + } + if exist { + existingProposals = append(existingProposals, p) + continue + } + } + newProposals = append(newProposals, p) + } + + notify := func() (*github.IssueComment, error) { + body := makeCommentBody(newProposals, existingProposals) + return ghClient.sendComment(ctx, event.Owner, event.Repo, event.PRNumber, body) + } + + if len(existingProposals) != 0 { + if len(newProposals) == 0 { + log.Printf("All of %d detected releases were already created before so no new release will be created\n", len(proposals)) + notify() + return + } + log.Printf("%d releases from %d detected ones were already created before so only %d releases will be created\n", len(existingProposals), len(proposals), len(newProposals)) + } + + releasesJSON, err := json.Marshal(newProposals) + if err != nil { + log.Fatalf("Failed to marshal releases: %v\n", err) + } + os.Setenv("GITHUB_OUTPUT", fmt.Sprintf("releases=%s", string(releasesJSON))) + if args.OutputReleasesFilePath != "" { + if err := os.WriteFile(args.OutputReleasesFilePath, releasesJSON, 0644); err != nil { + log.Fatalf("Failed to write releases JSON to %s: %v\n", args.OutputReleasesFilePath, err) + } + log.Printf("Successfully wrote releases JSON to %s\n", args.OutputReleasesFilePath) + } + + // Create GitHub releases if the event was push. + if event.Name == eventPush { + log.Printf("Will create %d GitHub releases\n", len(newProposals)) + for _, p := range newProposals { + r, err := ghClient.createRelease(ctx, event.Owner, event.Repo, p) + if err != nil { + log.Fatalf("Failed to create release %s: %v\n", p.Tag, err) + } + log.Printf("Successfully created a new GitHub release %s\n%s\n", r.GetTagName(), r.GetHTMLURL()) + } + + log.Printf("Successfully created all %d GitHub releases\n", len(newProposals)) + return + } + + // Otherwise, just leave a comment to show the changelogs. + comment, err := notify() + if err != nil { + log.Fatalf("Failed to send comment: %v\n", err) + } + + log.Printf("Successfully commented actions-gh-release result on pull request\n%s\n", *comment.HTMLURL) +} + +type arguments struct { + ReleaseFile string + Token string + OutputReleasesFilePath string +} + +func parseArgs(args []string) (arguments, error) { + var out arguments + + for _, arg := range args { + ps := strings.SplitN(arg, "=", 2) + if len(ps) != 2 { + continue + } + switch ps[0] { + case "release-file": + out.ReleaseFile = ps[1] + case "token": + out.Token = ps[1] + case "output-releases-file-path": + out.OutputReleasesFilePath = ps[1] + } + } + + if out.ReleaseFile == "" { + out.ReleaseFile = defaultReleaseFile + } + if out.Token == "" { + return out, fmt.Errorf("token argument must be set") + } + return out, nil +} diff --git a/.github/actions/gh-release/main_test.go b/.github/actions/gh-release/main_test.go new file mode 100644 index 0000000..e778e42 --- /dev/null +++ b/.github/actions/gh-release/main_test.go @@ -0,0 +1,22 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "embed" +) + +//go:embed testdata/* +var testdata embed.FS diff --git a/.github/actions/gh-release/release.go b/.github/actions/gh-release/release.go new file mode 100644 index 0000000..b468ab4 --- /dev/null +++ b/.github/actions/gh-release/release.go @@ -0,0 +1,452 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "regexp" + "strings" + + "github.com/creasty/defaults" + "github.com/google/go-github/v39/github" + "sigs.k8s.io/yaml" +) + +var ( + releaseNoteBlockRegex = regexp.MustCompile(`(?s)(?:Release note\*\*:\s*(?:\s*)?` + "```(?:release-note)?|```release-note)(.+?)```") + releaseNotePullNumberRegex = regexp.MustCompile(`#[0-9]+`) +) + +type ReleaseConfig struct { + Tag string `json:"tag,omitempty"` + Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + TargetCommitish string `json:"targetCommitish,omitempty"` + ReleaseNote string `json:"releaseNote,omitempty"` + Prerelease bool `json:"prerelease,omitempty"` + + CommitInclude ReleaseCommitMatcherConfig `json:"commitInclude,omitempty"` + CommitExclude ReleaseCommitMatcherConfig `json:"commitExclude,omitempty"` + + CommitCategories []ReleaseCommitCategoryConfig `json:"commitCategories,omitempty"` + ReleaseNoteGenerator ReleaseNoteGeneratorConfig `json:"releaseNoteGenerator,omitempty"` +} + +type ReleaseCommitCategoryConfig struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + ReleaseCommitMatcherConfig +} + +type ReleaseNoteGeneratorConfig struct { + ShowAbbrevHash bool `json:"showAbbrevHash,omitempty" default:"false"` + ShowCommitter *bool `json:"showCommitter,omitempty" default:"true"` + UseReleaseNoteBlock bool `json:"useReleaseNoteBlock,omitempty" default:"false"` + UsePullRequestMetadata bool `json:"usePullRequestMetadata,omitempty" default:"false"` + CommitInclude ReleaseCommitMatcherConfig `json:"commitInclude,omitempty"` + CommitExclude ReleaseCommitMatcherConfig `json:"commitExclude,omitempty"` + UsePullRequestLink bool `json:"usePullRequestLink,omitempty" default:"false"` +} + +type ReleaseCommitMatcherConfig struct { + ParentOfMergeCommit bool `json:"parentOfMergeCommit,omitempty"` + Prefixes []string `json:"prefixes,omitempty"` + Contains []string `json:"contains,omitempty"` +} + +func (c ReleaseCommitMatcherConfig) Empty() bool { + return len(c.Prefixes)+len(c.Contains) == 0 +} + +func (c ReleaseCommitMatcherConfig) Match(commit Commit, mergeCommit *Commit) bool { + if c.ParentOfMergeCommit && mergeCommit != nil { + if c.Match(*mergeCommit, nil) { + return true + } + } + for _, s := range c.Prefixes { + if strings.HasPrefix(commit.Subject, s) { + return true + } + } + for _, s := range c.Contains { + if strings.Contains(commit.Body, s) { + return true + } + } + return false +} + +func (c *ReleaseConfig) Validate() error { + if c.Tag == "" { + return fmt.Errorf("tag must be specified") + } + return nil +} + +func parseReleaseConfig(data []byte) (*ReleaseConfig, error) { + js, err := yaml.YAMLToJSON(data) + if err != nil { + return nil, err + } + + c := &ReleaseConfig{} + if err := json.Unmarshal(js, c); err != nil { + return nil, err + } + + if err := defaults.Set(c); err != nil { + return nil, err + } + for i := range c.CommitCategories { + if c.CommitCategories[i].ID == "" { + c.CommitCategories[i].ID = fmt.Sprintf("_category_%d", i) + } + } + + if err := c.Validate(); err != nil { + return nil, err + } + return c, nil +} + +type ReleaseProposal struct { + Tag string `json:"tag,omitempty"` + Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + TargetCommitish string `json:"targetCommitish,omitempty"` + ReleaseNote string `json:"releaseNote,omitempty"` + Prerelease bool `json:"prerelease,omitempty"` + + Owner string `json:"owner,omitempty"` + Repo string `json:"repo,omitempty"` + PreTag string `json:"preTag,omitempty"` + BaseCommit string `json:"baseCommit,omitempty"` + HeadCommit string `json:"headCommit,omitempty"` + Commits []ReleaseCommit `json:"commits,omitempty"` +} + +type ReleaseCommit struct { + Commit + ReleaseNote string `json:"releaseNote,omitempty"` + CategoryName string `json:"categoryName,omitempty"` + PullRequestNumber int `json:"pullRequestNumber,omitempty"` + PullRequestOwner string `json:"pullRequestOwner,omitempty"` +} + +func buildReleaseProposal(ctx context.Context, ghClient *githubClient, releaseFile string, gitExecPath, repoDir string, event *githubEvent) (*ReleaseProposal, error) { + configLoader := func(commit string) (*ReleaseConfig, error) { + data, err := readFileAtCommit(ctx, gitExecPath, repoDir, releaseFile, commit) + if err != nil { + return nil, err + } + return parseReleaseConfig(data) + } + + baseCfg, err := configLoader(event.BaseCommit) + if err != nil { + return nil, err + } + + headCfg, err := configLoader(event.HeadCommit) + if err != nil { + return nil, err + } + + // List all commits from the last release until now. + revisions := fmt.Sprintf("%s...%s", baseCfg.Tag, event.HeadCommit) + commits, err := listCommits(ctx, gitExecPath, repoDir, revisions) + if err != nil { + return nil, err + } + + releaseCommits, err := buildReleaseCommits(ctx, ghClient, commits, *headCfg, event) + if err != nil { + return nil, err + } + p := ReleaseProposal{ + Tag: headCfg.Tag, + Name: headCfg.Name, + Title: headCfg.Title, + TargetCommitish: headCfg.TargetCommitish, + ReleaseNote: headCfg.ReleaseNote, + Prerelease: headCfg.Prerelease, + Owner: event.Owner, + Repo: event.Repo, + PreTag: baseCfg.Tag, + BaseCommit: event.BaseCommit, + HeadCommit: event.HeadCommit, + Commits: releaseCommits, + } + + if p.Title == "" { + p.Title = fmt.Sprintf("Release %s", p.Tag) + } + if p.TargetCommitish == "" { + p.TargetCommitish = event.HeadCommit + } + if p.ReleaseNote == "" { + ln := renderReleaseNote(p, *headCfg) + p.ReleaseNote = string(ln) + } + + return &p, nil +} + +func buildReleaseCommits(ctx context.Context, ghClient *githubClient, commits []Commit, cfg ReleaseConfig, event *githubEvent) ([]ReleaseCommit, error) { + hashes := make(map[string]Commit, len(commits)) + for _, commit := range commits { + hashes[commit.Hash] = commit + } + + mergeCommits := make(map[string]*Commit, len(commits)) + for i := range commits { + commit := commits[i] + if !commit.IsMerge() { + continue + } + cursor, finish := commit.ParentHashes[1], commit.ParentHashes[0] + for { + parent, ok := hashes[cursor] + if !ok { + break + } + if parent.Hash == finish { + break + } + if len(parent.ParentHashes) != 1 { + break + } + mergeCommits[cursor] = &commit + cursor = parent.ParentHashes[0] + } + } + + gen, limit := cfg.ReleaseNoteGenerator, 1000 + prs := make(map[string]*github.PullRequest, limit) + if gen.UsePullRequestMetadata { + opts := &ListPullRequestOptions{ + State: PullRequestStateClosed, + Sort: PullRequestSortUpdated, + Direction: PullRequestDirectionDesc, + Limit: limit, + } + v, err := ghClient.listPullRequests(ctx, event.Owner, event.Repo, opts) + if err != nil { + return nil, err + } + for i := range v { + sha := v[i].GetMergeCommitSHA() + // if merge commit sha is empty, the test merge commit was not generated. + // this cause when PR is conflict and closed without resolved. + if sha == "" { + continue + } + prs[sha] = v[i] + } + } + + getPullRequest := func(commit Commit) (*github.PullRequest, error) { + if !commit.IsMerge() { + return nil, nil + } + if pr, ok := prs[commit.Hash]; ok { + return pr, nil + } + prNumber, ok := commit.PullRequestNumber() + if !ok { + return nil, nil + } + pr, err := ghClient.getPullRequest(ctx, event.Owner, event.Repo, prNumber) + if err != nil { + return nil, fmt.Errorf("failed to get pull request by number %d: %v", prNumber, err) + } + return pr, nil + } + + out := make([]ReleaseCommit, 0, len(commits)) + for _, commit := range commits { + + // Exclude was specified and matched. + if !cfg.CommitExclude.Empty() && cfg.CommitExclude.Match(commit, mergeCommits[commit.Hash]) { + continue + } + + // Include was specified and not matched. + if !cfg.CommitInclude.Empty() && !cfg.CommitInclude.Match(commit, mergeCommits[commit.Hash]) { + continue + } + + c := ReleaseCommit{ + Commit: commit, + ReleaseNote: extractReleaseNote(commit.Subject, commit.Body, gen.UseReleaseNoteBlock), + CategoryName: determineCommitCategory(commit, mergeCommits[commit.Hash], cfg.CommitCategories), + } + + if gen.UsePullRequestMetadata { + pr, err := getPullRequest(commit) + if err != nil { + // only error logging, ignore error + log.Printf("Failed to get pull request: %v\n", err) + } + if pr != nil { + c.PullRequestNumber = pr.GetNumber() + c.PullRequestOwner = pr.GetUser().GetLogin() + c.ReleaseNote = extractReleaseNote(pr.GetTitle(), pr.GetBody(), gen.UseReleaseNoteBlock) + } + } + + out = append(out, c) + } + return out, nil +} + +func extractReleaseNote(def, body string, useReleaseNoteBlock bool) string { + if !useReleaseNoteBlock { + return def + } + + subs := releaseNoteBlockRegex.FindStringSubmatch(body) + if len(subs) != 2 { + return def + } + if rn := strings.TrimSpace(subs[1]); rn != "" { + return rn + } + return def +} + +func determineCommitCategory(commit Commit, mergeCommit *Commit, categories []ReleaseCommitCategoryConfig) string { + for _, c := range categories { + if c.ReleaseCommitMatcherConfig.Empty() { + return c.ID + } + if c.ReleaseCommitMatcherConfig.Match(commit, mergeCommit) { + return c.ID + } + } + return "" +} + +func renderReleaseNote(p ReleaseProposal, cfg ReleaseConfig) []byte { + var b strings.Builder + b.WriteString(fmt.Sprintf("## Release %s with changes since %s\n\n", p.Tag, p.PreTag)) + + gen := cfg.ReleaseNoteGenerator + renderCommit := func(c ReleaseCommit) { + // If the release note contains pull numbers, replaces it with its url. + if gen.UsePullRequestLink { + numbers := releaseNotePullNumberRegex.FindAllString(c.ReleaseNote, -1) + if len(numbers) != 0 { + ns := make(map[string]struct{}, len(numbers)) + for _, n := range numbers { + ns[n] = struct{}{} + } + for k := range ns { + link := fmt.Sprintf("[%s](https://github.com/%s/%s/pull/%s)", k, p.Owner, p.Repo, string(k[1:])) + c.ReleaseNote = strings.ReplaceAll(c.ReleaseNote, k, link) + } + } + } + b.WriteString(fmt.Sprintf("* %s", c.ReleaseNote)) + + // If using a merge commit, prepares another options to add extra info. + if gen.UsePullRequestMetadata && c.PullRequestNumber != 0 { + b.WriteString(fmt.Sprintf(" ([#%d](https://github.com/%s/%s/pull/%d))", c.PullRequestNumber, p.Owner, p.Repo, c.PullRequestNumber)) + if !gen.UseReleaseNoteBlock && c.PullRequestOwner != "" { + b.WriteString(fmt.Sprintf(" - by @%s", c.PullRequestOwner)) + } + b.WriteString("\n") + return + } + + if gen.ShowAbbrevHash { + b.WriteString(fmt.Sprintf(" [%s](https://github.com/%s/%s/commit/%s)", c.AbbreviatedHash, p.Owner, p.Repo, c.Hash)) + } + if gen.ShowCommitter != nil && *gen.ShowCommitter { + b.WriteString(fmt.Sprintf(" - by %s", c.Committer)) + } + b.WriteString("\n") + } + + hashes := make(map[string]Commit, len(p.Commits)) + for _, commit := range p.Commits { + hashes[commit.Hash] = commit.Commit + } + + mergeCommits := make(map[string]*Commit, len(p.Commits)) + for i := range p.Commits { + commit := p.Commits[i] + if !commit.IsMerge() { + continue + } + cursor, finish := commit.ParentHashes[1], commit.ParentHashes[0] + for { + parent, ok := hashes[cursor] + if !ok { + break + } + if parent.Hash == finish { + break + } + if len(parent.ParentHashes) != 1 { + break + } + mergeCommits[cursor] = &commit.Commit + cursor = parent.ParentHashes[0] + } + } + + filteredCommits := make([]ReleaseCommit, 0, len(p.Commits)) + for _, c := range p.Commits { + // Exclude was specified and matched. + if !cfg.ReleaseNoteGenerator.CommitExclude.Empty() && cfg.ReleaseNoteGenerator.CommitExclude.Match(c.Commit, mergeCommits[c.Hash]) { + continue + } + // Include was specified and not matched. + if !cfg.ReleaseNoteGenerator.CommitInclude.Empty() && !cfg.ReleaseNoteGenerator.CommitInclude.Match(c.Commit, mergeCommits[c.Hash]) { + continue + } + filteredCommits = append(filteredCommits, c) + } + + for _, ctg := range cfg.CommitCategories { + commits := make([]ReleaseCommit, 0, 0) + for _, c := range filteredCommits { + if c.CategoryName == ctg.ID { + commits = append(commits, c) + } + } + if len(commits) == 0 { + continue + } + b.WriteString(fmt.Sprintf("### %s\n\n", ctg.Title)) + for _, c := range commits { + renderCommit(c) + } + b.WriteString("\n") + } + + for _, c := range filteredCommits { + if c.CategoryName == "" { + renderCommit(c) + } + } + + return []byte(b.String()) +} diff --git a/.github/actions/gh-release/release_test.go b/.github/actions/gh-release/release_test.go new file mode 100644 index 0000000..3170931 --- /dev/null +++ b/.github/actions/gh-release/release_test.go @@ -0,0 +1,429 @@ +// Copyright 2024 The PipeCD Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseReleaseConfig(t *testing.T) { + fakeShowCommitter := true + testcases := []struct { + name string + configFile string + expected *ReleaseConfig + expectedErr error + }{ + { + name: "empty config", + configFile: "testdata/empty-config.txt", + expectedErr: fmt.Errorf("tag must be specified"), + }, + { + name: "valid config", + configFile: "testdata/valid-config.txt", + expected: &ReleaseConfig{ + Tag: "v1.1.0", + Name: "hello", + CommitInclude: ReleaseCommitMatcherConfig{ + Contains: []string{ + "app/hello", + }, + }, + CommitExclude: ReleaseCommitMatcherConfig{ + Prefixes: []string{ + "Merge pull request #", + }, + }, + CommitCategories: []ReleaseCommitCategoryConfig{ + ReleaseCommitCategoryConfig{ + ID: "_category_0", + Title: "Breaking Changes", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{ + Contains: []string{"change-category/breaking-change"}, + }, + }, + ReleaseCommitCategoryConfig{ + ID: "_category_1", + Title: "New Features", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{ + Contains: []string{"change-category/new-feature"}, + }, + }, + ReleaseCommitCategoryConfig{ + ID: "_category_2", + Title: "Notable Changes", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{ + Contains: []string{"change-category/notable-change"}, + }, + }, + ReleaseCommitCategoryConfig{ + ID: "_category_3", + Title: "Internal Changes", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{}, + }, + }, + ReleaseNoteGenerator: ReleaseNoteGeneratorConfig{ + ShowCommitter: &fakeShowCommitter, + UseReleaseNoteBlock: true, + }, + }, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + data, err := testdata.ReadFile(tc.configFile) + require.NoError(t, err) + + cfg, err := parseReleaseConfig(data) + assert.Equal(t, tc.expected, cfg) + assert.Equal(t, tc.expectedErr, err) + }) + } +} + +func TestBuildReleaseCommits(t *testing.T) { + ctx := context.Background() + fakeShowCommitter := true + config := ReleaseConfig{ + Tag: "v1.1.0", + Name: "hello", + CommitInclude: ReleaseCommitMatcherConfig{ + Contains: []string{ + "app/hello", + }, + }, + CommitExclude: ReleaseCommitMatcherConfig{ + Prefixes: []string{ + "Merge pull request #", + }, + }, + CommitCategories: []ReleaseCommitCategoryConfig{ + ReleaseCommitCategoryConfig{ + ID: "breaking-change", + Title: "Breaking Changes", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{ + Contains: []string{"change-category/breaking-change"}, + }, + }, + ReleaseCommitCategoryConfig{ + ID: "new-feature", + Title: "New Features", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{ + Contains: []string{"change-category/new-feature"}, + }, + }, + ReleaseCommitCategoryConfig{ + ID: "notable-change", + Title: "Notable Changes", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{ + Contains: []string{"change-category/notable-change"}, + }, + }, + ReleaseCommitCategoryConfig{ + ID: "internal-change", + Title: "Internal Changes", + ReleaseCommitMatcherConfig: ReleaseCommitMatcherConfig{}, + }, + }, + ReleaseNoteGenerator: ReleaseNoteGeneratorConfig{ + ShowCommitter: &fakeShowCommitter, + UseReleaseNoteBlock: true, + }, + } + + testcases := []struct { + name string + commits []Commit + config ReleaseConfig + expected []ReleaseCommit + wantErr bool + }{ + { + name: "empty", + expected: []ReleaseCommit{}, + wantErr: false, + }, + { + name: "ok", + commits: []Commit{ + Commit{ + Subject: "Commit 1 message", + Body: "commit 1\napp/hello\n- change-category/breaking-change", + }, + Commit{ + Subject: "Commit 2 message", + Body: "commit 2\napp/hello", + }, + Commit{ + Subject: "Commit 3 message", + Body: "commit 3\napp/hello\n- change-category/notable-change", + }, + Commit{ + Subject: "Commit 4 message", + Body: "commit 4\napp/hello\n```release-note\nCommit 4 release note\n```\n- change-category/notable-change\n", + }, + Commit{ + Subject: "Commit 5 message", + Body: "commit 5", + }, + }, + config: config, + expected: []ReleaseCommit{ + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 1 message", + Body: "commit 1\napp/hello\n- change-category/breaking-change", + }, + CategoryName: "breaking-change", + ReleaseNote: "Commit 1 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 2 message", + Body: "commit 2\napp/hello", + }, + CategoryName: "internal-change", + ReleaseNote: "Commit 2 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 3 message", + Body: "commit 3\napp/hello\n- change-category/notable-change", + }, + CategoryName: "notable-change", + ReleaseNote: "Commit 3 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 4 message", + Body: "commit 4\napp/hello\n```release-note\nCommit 4 release note\n```\n- change-category/notable-change\n", + }, + CategoryName: "notable-change", + ReleaseNote: "Commit 4 release note", + }, + }, + wantErr: false, + }, + { + name: "Add include condition: parent of merge commit", + commits: []Commit{ + { + Hash: "a", + ParentHashes: []string{"z"}, + Subject: "Commit 1 message", + Body: "commit 1", + }, + { + Hash: "b", + ParentHashes: []string{"a"}, + Subject: "Commit 2 message", + Body: "commit 2", + }, + { + Hash: "c", + ParentHashes: []string{"z", "b"}, + Subject: "Commit 3 message", + Body: "commit 3\napp/hello\n- change-category/notable-change", + }, + { + Hash: "d", + ParentHashes: []string{"c"}, + Subject: "Commit 4 message", + Body: "commit 4", + }, + { + Hash: "e", + ParentHashes: []string{"c", "d"}, + Subject: "Commit 5 message", + Body: "commit 5", + }, + }, + config: func(base ReleaseConfig) ReleaseConfig { + base.CommitInclude.ParentOfMergeCommit = true + return base + }(config), + expected: []ReleaseCommit{ + { + Commit: Commit{ + Hash: "a", + ParentHashes: []string{"z"}, + Subject: "Commit 1 message", + Body: "commit 1", + }, + CategoryName: "internal-change", + ReleaseNote: "Commit 1 message", + }, + { + Commit: Commit{ + Hash: "b", + ParentHashes: []string{"a"}, + Subject: "Commit 2 message", + Body: "commit 2", + }, + CategoryName: "internal-change", + ReleaseNote: "Commit 2 message", + }, + { + Commit: Commit{ + Hash: "c", + ParentHashes: []string{"z", "b"}, + Subject: "Commit 3 message", + Body: "commit 3\napp/hello\n- change-category/notable-change", + }, + CategoryName: "notable-change", + ReleaseNote: "Commit 3 message", + }, + }, + wantErr: false, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got, err := buildReleaseCommits(ctx, nil, tc.commits, tc.config, nil) + assert.Equal(t, tc.wantErr, err != nil) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestRenderReleaseNote(t *testing.T) { + testcases := []struct { + name string + proposal ReleaseProposal + config ReleaseConfig + expected string + }{ + { + name: "no category", + proposal: ReleaseProposal{ + Tag: "v0.2.0", + PreTag: "v0.1.0", + Commits: []ReleaseCommit{ + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 1 message", + Body: "commit 1\n- change-category/breaking-change", + }, + ReleaseNote: "Commit 1 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 2 message", + Body: "commit 2", + }, + ReleaseNote: "Commit 2 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 3 message", + Body: "commit 3\n- change-category/notable-change", + }, + ReleaseNote: "Commit 3 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 4 message", + Body: "commit 4\n```release-note\nCommit 4 release note\n```\n- change-category/notable-change", + }, + ReleaseNote: "Commit 4 release note", + }, + }, + }, + config: ReleaseConfig{}, + expected: "testdata/no-category-release-note.txt", + }, + { + name: "has category", + proposal: ReleaseProposal{ + Tag: "v0.2.0", + PreTag: "v0.1.0", + Commits: []ReleaseCommit{ + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 1 message", + Body: "commit 1\n- change-category/breaking-change", + }, + CategoryName: "breaking-change", + ReleaseNote: "Commit 1 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 2 message", + Body: "commit 2", + }, + CategoryName: "internal-change", + ReleaseNote: "Commit 2 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 3 message", + Body: "commit 3\n- change-category/notable-change", + }, + CategoryName: "notable-change", + ReleaseNote: "Commit 3 message", + }, + ReleaseCommit{ + Commit: Commit{ + Subject: "Commit 4 message", + Body: "commit 4\n```release-note\nCommit 4 release note\n```\n- change-category/notable-change", + }, + CategoryName: "notable-change", + ReleaseNote: "Commit 4 release note", + }, + }, + }, + config: ReleaseConfig{ + CommitCategories: []ReleaseCommitCategoryConfig{ + ReleaseCommitCategoryConfig{ + ID: "breaking-change", + Title: "Breaking Changes", + }, + ReleaseCommitCategoryConfig{ + ID: "new-feature", + Title: "New Features", + }, + ReleaseCommitCategoryConfig{ + ID: "notable-change", + Title: "Notable Changes", + }, + ReleaseCommitCategoryConfig{ + ID: "internal-change", + Title: "Internal Changes", + }, + }, + }, + expected: "testdata/has-category-release-note.txt", + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + got := renderReleaseNote(tc.proposal, tc.config) + + expected, err := testdata.ReadFile(tc.expected) + require.NoError(t, err) + + assert.Equal(t, string(expected), string(got)) + }) + } +} diff --git a/.github/workflows/gh-release-preview.yml b/.github/workflows/gh-release-preview.yml new file mode 100644 index 0000000..e58139e --- /dev/null +++ b/.github/workflows/gh-release-preview.yml @@ -0,0 +1,33 @@ +name: gh-release-preview + +on: + pull_request: + types: + - opened + - synchronize + branches: + - main + paths: + - "**/RELEASE" + +jobs: + gh-release: + runs-on: ubuntu-latest + permissions: + contents: write + issues: write + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Create temporary access token by GitHub App + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: pipe-cd/actions-gh-release@v2.6.0 + with: + release_file: "**/RELEASE" + token: ${{ steps.app-token.outputs.token }} + diff --git a/.github/workflows/gh-release.yaml b/.github/workflows/gh-release.yaml index 87cb66e..166f818 100644 --- a/.github/workflows/gh-release.yaml +++ b/.github/workflows/gh-release.yaml @@ -4,15 +4,23 @@ on: push: branches: - main - paths: - - "**/RELEASE" + # paths: + # - "**/RELEASE" + # pull_request: + # types: + # - opened + # - synchronize + # branches: + # - main + # paths: + # - "**/RELEASE" workflow_dispatch: jobs: gh-release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v3 with: fetch-depth: 0 - uses: pipe-cd/actions-gh-release@v2.6.0 diff --git a/release/RELEASE b/release/RELEASE old mode 100644 new mode 100755 index 7cb0a4c..096a22d --- a/release/RELEASE +++ b/release/RELEASE @@ -1,4 +1,4 @@ -tag: v0.1.1 +tag: v0.1.2 prerelease: false commitCategories: