From 2f7e8b9afeca39bb258ef7d8909d559f8aa86c32 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 23 Aug 2025 20:23:12 +0200 Subject: [PATCH 01/20] deps: update actions/checkout digest to 08eba0b (#220) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 +++--- .github/workflows/docs.yaml | 2 +- .github/workflows/mirror.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/releaser-pleaser.yaml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f2a8386..79d9779 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 @@ -27,7 +27,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 @@ -46,7 +46,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index f1fb5d1..912615e 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -13,7 +13,7 @@ jobs: id-token: write # To update the deployment status steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 with: lfs: "true" diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index e287aed..d7feadb 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -11,7 +11,7 @@ jobs: REMOTE: mirror steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 with: # Need all to fetch all tags so we can push them fetch-depth: 0 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 6da204d..bcc947d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - name: Set up Go uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 diff --git a/.github/workflows/releaser-pleaser.yaml b/.github/workflows/releaser-pleaser.yaml index aa5097f..6e79306 100644 --- a/.github/workflows/releaser-pleaser.yaml +++ b/.github/workflows/releaser-pleaser.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 with: ref: main From 6237c9b666f2b46b4f491536d7a8ac396d4c61af Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 23 Aug 2025 20:23:41 +0200 Subject: [PATCH 02/20] deps: update codecov/codecov-action digest to fdcc847 (#229) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 79d9779..9d137dc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,7 +38,7 @@ jobs: run: go test -v -race -coverpkg=./... -coverprofile=coverage.txt ./... - name: Upload results to Codecov - uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 + uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} From 1e9e0aa5d908a2dff5f6997186badaf29aaf76f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mattis=20Kr=C3=A4mer?= Date: Sat, 23 Aug 2025 22:05:52 +0200 Subject: [PATCH 03/20] feat: add updater for package.json (#213) --- action.yml | 5 ++ cmd/rp/cmd/run.go | 23 +++++--- docs/reference/github-action.md | 11 ++-- docs/reference/gitlab-cicd-component.md | 1 + internal/git/git.go | 3 +- internal/updater/changelog.go | 2 +- internal/updater/changelog_test.go | 25 +++++---- internal/updater/generic.go | 2 +- internal/updater/generic_test.go | 20 ++++--- internal/updater/packagejson.go | 30 +++++++++++ internal/updater/packagejson_test.go | 70 +++++++++++++++++++++++++ internal/updater/updater.go | 2 +- internal/updater/updater_test.go | 17 +++--- templates/run.yml | 7 ++- 14 files changed, 174 insertions(+), 44 deletions(-) create mode 100644 internal/updater/packagejson.go create mode 100644 internal/updater/packagejson_test.go diff --git a/action.yml b/action.yml index 225e3cc..5a3d233 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,10 @@ inputs: description: 'List of files that are scanned for version references.' required: false default: "" + update-package-json: + description: 'Update version field in package.json file.' + required: false + default: "false" # Remember to update docs/reference/github-action.md outputs: {} runs: @@ -27,6 +31,7 @@ runs: - --forge=github - --branch=${{ inputs.branch }} - --extra-files="${{ inputs.extra-files }}" + - ${{ inputs.update-package-json == 'true' && '--update-package-json' || '' }} env: GITHUB_TOKEN: "${{ inputs.token }}" GITHUB_USER: "oauth2" diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index ec11e24..fa48cc0 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -21,21 +21,22 @@ var runCmd = &cobra.Command{ } var ( - flagForge string - flagBranch string - flagOwner string - flagRepo string - flagExtraFiles string + flagForge string + flagBranch string + flagOwner string + flagRepo string + flagExtraFiles string + flagUpdatePackageJson bool ) func init() { rootCmd.AddCommand(runCmd) - runCmd.PersistentFlags().StringVar(&flagForge, "forge", "", "") runCmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") runCmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") + runCmd.PersistentFlags().BoolVar(&flagUpdatePackageJson, "update-package-json", false, "") } func run(cmd *cobra.Command, _ []string) error { @@ -48,6 +49,7 @@ func run(cmd *cobra.Command, _ []string) error { "branch", flagBranch, "owner", flagOwner, "repo", flagRepo, + "update-package-json", flagUpdatePackageJson, ) var f forge.Forge @@ -81,6 +83,13 @@ func run(cmd *cobra.Command, _ []string) error { extraFiles := parseExtraFiles(flagExtraFiles) + updaters := []updater.NewUpdater{updater.Generic} + + if flagUpdatePackageJson { + logger.DebugContext(ctx, "package.json updater enabled") + updaters = append(updaters, updater.PackageJson) + } + releaserPleaser := rp.New( f, logger, @@ -88,7 +97,7 @@ func run(cmd *cobra.Command, _ []string) error { conventionalcommits.NewParser(logger), versioning.SemVer, extraFiles, - []updater.NewUpdater{updater.Generic}, + updaters, ) return releaserPleaser.Run(ctx) diff --git a/docs/reference/github-action.md b/docs/reference/github-action.md index eec9789..3a849dc 100644 --- a/docs/reference/github-action.md +++ b/docs/reference/github-action.md @@ -14,11 +14,12 @@ The action does not support floating tags (e.g. `v1`) right now ([#31](https://g The following inputs are supported by the `apricote/releaser-pleaser` GitHub Action. -| Input | Description | Default | Example | -| ------------- | :----------------------------------------------------- | --------------: | -------------------------------------------------------------------: | -| `branch` | This branch is used as the target for releases. | `main` | `master` | -| `token` | GitHub token for creating and updating release PRs | `$GITHUB_TOKEN` | `${{secrets.RELEASER_PLEASER_TOKEN}}` | -| `extra-files` | List of files that are scanned for version references. | `""` |
version/version.go
deploy/deployment.yaml
| +| Input | Description | Default | Example | +| --------------------- | :------------------------------------------------------ | --------------: | -------------------------------------------------------------------: | +| `branch` | This branch is used as the target for releases. | `main` | `master` | +| `token` | GitHub token for creating and updating release PRs | `$GITHUB_TOKEN` | `${{secrets.RELEASER_PLEASER_TOKEN}}` | +| `extra-files` | List of files that are scanned for version references. | `""` |
version/version.go
deploy/deployment.yaml
| +| `update-package-json` | Update version field in package.json file. | `false` | `true` | ## Outputs diff --git a/docs/reference/gitlab-cicd-component.md b/docs/reference/gitlab-cicd-component.md index b22d5b2..01a6fc2 100644 --- a/docs/reference/gitlab-cicd-component.md +++ b/docs/reference/gitlab-cicd-component.md @@ -23,5 +23,6 @@ The following inputs are supported by the component. | `branch` | This branch is used as the target for releases. | `main` | `master` | | `token` (**required**) | GitLab access token for creating and updating release PRs | | `$RELEASER_PLEASER_TOKEN` | | `extra-files` | List of files that are scanned for version references. | `""` |
version/version.go
deploy/deployment.yaml
| +| `update-package-json` | Update version field in package.json file. | `false` | `true` | | `stage` | Stage the job runs in. Must exists. | `build` | `test` | | `needs` | Other jobs the releaser-pleaser job depends on. | `[]` |
- validate-foo
- prepare-bar
| diff --git a/internal/git/git.go b/internal/git/git.go index d1db11b..b9c750e 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -6,6 +6,7 @@ import ( "io" "log/slog" "os" + "path/filepath" "time" "github.com/go-git/go-git/v5" @@ -144,7 +145,7 @@ func (r *Repository) UpdateFile(_ context.Context, path string, create bool, upd updatedContent := string(content) for _, update := range updaters { - updatedContent, err = update(updatedContent) + updatedContent, err = update(updatedContent, filepath.Base(path)) if err != nil { return fmt.Errorf("failed to run updater on file %s", path) } diff --git a/internal/updater/changelog.go b/internal/updater/changelog.go index 8bdb9f6..8d6d68c 100644 --- a/internal/updater/changelog.go +++ b/internal/updater/changelog.go @@ -15,7 +15,7 @@ var ( ) func Changelog(info ReleaseInfo) Updater { - return func(content string) (string, error) { + return func(content string, filename string) (string, error) { headerIndex := ChangelogUpdaterHeaderRegex.FindStringIndex(content) if headerIndex == nil && len(content) != 0 { return "", fmt.Errorf("unexpected format of CHANGELOG.md, header does not match") diff --git a/internal/updater/changelog_test.go b/internal/updater/changelog_test.go index 917cd14..35878b9 100644 --- a/internal/updater/changelog_test.go +++ b/internal/updater/changelog_test.go @@ -9,11 +9,12 @@ import ( func TestChangelogUpdater_UpdateContent(t *testing.T) { tests := []updaterTestCase{ { - name: "empty file", - content: "", - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"}, - want: "# Changelog\n\n## v1.0.0\n", - wantErr: assert.NoError, + name: "empty file", + content: "", + filename: "CHANGELOG.md", + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"}, + want: "# Changelog\n\n## v1.0.0\n", + wantErr: assert.NoError, }, { name: "well-formatted changelog", @@ -27,7 +28,8 @@ func TestChangelogUpdater_UpdateContent(t *testing.T) { ### Bazuuum `, - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, + filename: "CHANGELOG.md", + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, want: `# Changelog ## v1.0.0 @@ -45,11 +47,12 @@ func TestChangelogUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "error on invalid header", - content: "What even is this file?", - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, - want: "", - wantErr: assert.Error, + name: "error on invalid header", + content: "What even is this file?", + filename: "CHANGELOG.md", + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, + want: "", + wantErr: assert.Error, }, } for _, tt := range tests { diff --git a/internal/updater/generic.go b/internal/updater/generic.go index b8d73b0..1883c1a 100644 --- a/internal/updater/generic.go +++ b/internal/updater/generic.go @@ -8,7 +8,7 @@ import ( var GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`) func Generic(info ReleaseInfo) Updater { - return func(content string) (string, error) { + return func(content string, filename string) (string, error) { // We strip the "v" prefix to avoid adding/removing it from the users input. version := strings.TrimPrefix(info.Version, "v") diff --git a/internal/updater/generic_test.go b/internal/updater/generic_test.go index e0a8d1d..4cc8952 100644 --- a/internal/updater/generic_test.go +++ b/internal/updater/generic_test.go @@ -9,8 +9,9 @@ import ( func TestGenericUpdater_UpdateContent(t *testing.T) { tests := []updaterTestCase{ { - name: "single line", - content: "v1.0.0 // x-releaser-pleaser-version", + name: "single line", + content: "v1.0.0 // x-releaser-pleaser-version", + filename: "version.txt", info: ReleaseInfo{ Version: "v1.2.0", }, @@ -18,8 +19,9 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "multiline line", - content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n", + name: "multiline line", + content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n", + filename: "version.txt", info: ReleaseInfo{ Version: "v1.2.0", }, @@ -27,8 +29,9 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "invalid existing version", - content: "1.0 // x-releaser-pleaser-version", + name: "invalid existing version", + content: "1.0 // x-releaser-pleaser-version", + filename: "version.txt", info: ReleaseInfo{ Version: "v1.2.0", }, @@ -36,8 +39,9 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "complicated line", - content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar", + name: "complicated line", + content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar", + filename: "version.txt", info: ReleaseInfo{ Version: "v1.2.0", }, diff --git a/internal/updater/packagejson.go b/internal/updater/packagejson.go new file mode 100644 index 0000000..58f2cc3 --- /dev/null +++ b/internal/updater/packagejson.go @@ -0,0 +1,30 @@ +package updater + +import ( + "regexp" + "strings" +) + +// PackageJson creates an updater that modifies the version field in package.json files +func PackageJson(info ReleaseInfo) Updater { + return func(content string, filename string) (string, error) { + if filename != "package.json" { + return content, nil // No update needed for non-package.json files + } + // We strip the "v" prefix to match npm versioning convention + version := strings.TrimPrefix(info.Version, "v") + + // Regex to match "version": "..." with flexible whitespace and quote styles + versionRegex := regexp.MustCompile(`("version"\s*:\s*)"[^"]*"`) + + // Check if the file contains a version field + if !versionRegex.MatchString(content) { + return content, nil + } + + // Replace the version value while preserving the original formatting + updatedContent := versionRegex.ReplaceAllString(content, `${1}"`+version+`"`) + + return updatedContent, nil + } +} diff --git a/internal/updater/packagejson_test.go b/internal/updater/packagejson_test.go new file mode 100644 index 0000000..4fd196e --- /dev/null +++ b/internal/updater/packagejson_test.go @@ -0,0 +1,70 @@ +package updater + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPackageJsonUpdater(t *testing.T) { + tests := []updaterTestCase{ + { + name: "simple package.json", + content: `{"name":"test","version":"1.0.0"}`, + filename: "package.json", + info: ReleaseInfo{ + Version: "v2.0.5", + }, + want: `{"name":"test","version":"2.0.5"}`, + wantErr: assert.NoError, + }, + { + name: "simple package.json, wrong name", + content: `{"name":"test","version":"1.0.0"}`, + filename: "nopackage.json", + info: ReleaseInfo{ + Version: "v2.0.5", + }, + want: `{"name":"test","version":"1.0.0"}`, + wantErr: assert.NoError, + }, + { + name: "complex package.json", + content: "{\n \"name\": \"test\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}", + filename: "package.json", + info: ReleaseInfo{ + Version: "v2.0.0", + }, + want: "{\n \"name\": \"test\",\n \"version\": \"2.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}", + wantErr: assert.NoError, + }, + { + name: "invalid json", + content: `not json`, + filename: "package.json", + info: ReleaseInfo{ + Version: "v2.0.0", + }, + want: `not json`, + wantErr: assert.NoError, + }, + { + name: "json without version", + content: `{"name":"test"}`, + filename: "package.json", + info: ReleaseInfo{ + Version: "v2.0.0", + }, + want: `{"name":"test"}`, + wantErr: assert.NoError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fmt.Println("Running updater test for PackageJson") + runUpdaterTest(t, PackageJson, tt) + }) + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index fb773b4..f5fd677 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -5,7 +5,7 @@ type ReleaseInfo struct { ChangelogEntry string } -type Updater func(string) (string, error) +type Updater func(content string, filename string) (string, error) type NewUpdater func(ReleaseInfo) Updater diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 0c0c40e..17162ef 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -8,19 +8,20 @@ import ( ) type updaterTestCase struct { - name string - content string - info ReleaseInfo - want string - wantErr assert.ErrorAssertionFunc + name string + content string + filename string + info ReleaseInfo + want string + wantErr assert.ErrorAssertionFunc } func runUpdaterTest(t *testing.T, constructor NewUpdater, tt updaterTestCase) { t.Helper() - got, err := constructor(tt.info)(tt.content) - if !tt.wantErr(t, err, fmt.Sprintf("Updater(%v, %v)", tt.content, tt.info)) { + got, err := constructor(tt.info)(tt.content, tt.filename) + if !tt.wantErr(t, err, fmt.Sprintf("Updater(%v, %v, %v)", tt.content, tt.filename, tt.info)) { return } - assert.Equalf(t, tt.want, got, "Updater(%v, %v)", tt.content, tt.info) + assert.Equalf(t, tt.want, got, "Updater(%v, %v, %v)", tt.content, tt.filename, tt.info) } diff --git a/templates/run.yml b/templates/run.yml index c18a330..66037b1 100644 --- a/templates/run.yml +++ b/templates/run.yml @@ -12,6 +12,10 @@ spec: description: 'List of files that are scanned for version references.' default: "" + update-package-json: + description: 'Update version field in package.json file.' + default: "false" + stage: default: build description: 'Defines the build stage' @@ -49,4 +53,5 @@ releaser-pleaser: rp run \ --forge=gitlab \ --branch=$[[ inputs.branch ]] \ - --extra-files="$[[ inputs.extra-files ]]" + --extra-files="$[[ inputs.extra-files ]]" \ + $([[ inputs.update-package-json == "true" ]] && echo "--update-package-json" || echo "") From f1aa1a2ef43aa646f8385e753b43712b9de36468 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 23 Aug 2025 22:14:34 +0200 Subject: [PATCH 04/20] refactor: let updaters define the files they want to run on (#233) This change reverses the responsibility for which files the updaters are run on. Now each updater can specify the list of files and wether the files should be created when they do not exist yet. This simplifies the handling of each update in releaserpleaser.go, as we can just iterate over all updaters and call it for each file of that updater. Also update the flags to allow users to easily define which updaters should run. --- action.yml | 12 +++--- cmd/rp/cmd/run.go | 53 +++++++++++++++++++------ cmd/rp/cmd/run_test.go | 40 +++++++++++++++++++ docs/SUMMARY.md | 1 + docs/guides/updating-arbitrary-files.md | 10 +++-- docs/reference/github-action.md | 16 ++++---- docs/reference/gitlab-cicd-component.md | 16 ++++---- docs/reference/glossary.md | 28 +++++++++---- docs/reference/updaters.md | 33 +++++++++++++++ internal/git/git.go | 15 ++----- internal/updater/changelog.go | 19 ++++++++- internal/updater/changelog_test.go | 37 +++++++++-------- internal/updater/generic.go | 22 +++++++++- internal/updater/generic_test.go | 32 ++++++++------- internal/updater/packagejson.go | 25 ++++++++---- internal/updater/packagejson_test.go | 44 +++++++++----------- internal/updater/updater.go | 6 ++- internal/updater/updater_test.go | 19 +++++---- releaserpleaser.go | 20 ++++------ templates/run.yml | 10 ++--- 20 files changed, 307 insertions(+), 151 deletions(-) create mode 100644 docs/reference/updaters.md diff --git a/action.yml b/action.yml index 5a3d233..651ca09 100644 --- a/action.yml +++ b/action.yml @@ -14,15 +14,15 @@ inputs: required: false default: ${{ github.token }} extra-files: - description: 'List of files that are scanned for version references.' + description: 'List of files that are scanned for version references by the generic updater.' required: false default: "" - update-package-json: - description: 'Update version field in package.json file.' + updaters: + description: "List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic" required: false - default: "false" + default: "" # Remember to update docs/reference/github-action.md -outputs: {} +outputs: { } runs: using: 'docker' image: docker://ghcr.io/apricote/releaser-pleaser:v0.6.1 # x-releaser-pleaser-version @@ -31,7 +31,7 @@ runs: - --forge=github - --branch=${{ inputs.branch }} - --extra-files="${{ inputs.extra-files }}" - - ${{ inputs.update-package-json == 'true' && '--update-package-json' || '' }} + - --updaters="${{ inputs.updaters }}" env: GITHUB_TOKEN: "${{ inputs.token }}" GITHUB_USER: "oauth2" diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index fa48cc0..202ba3d 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "slices" "strings" "github.com/spf13/cobra" @@ -21,12 +22,12 @@ var runCmd = &cobra.Command{ } var ( - flagForge string - flagBranch string - flagOwner string - flagRepo string - flagExtraFiles string - flagUpdatePackageJson bool + flagForge string + flagBranch string + flagOwner string + flagRepo string + flagExtraFiles string + flagUpdaters []string ) func init() { @@ -36,7 +37,7 @@ func init() { runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") runCmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") - runCmd.PersistentFlags().BoolVar(&flagUpdatePackageJson, "update-package-json", false, "") + runCmd.PersistentFlags().StringSliceVar(&flagUpdaters, "updaters", []string{}, "") } func run(cmd *cobra.Command, _ []string) error { @@ -49,7 +50,6 @@ func run(cmd *cobra.Command, _ []string) error { "branch", flagBranch, "owner", flagOwner, "repo", flagRepo, - "update-package-json", flagUpdatePackageJson, ) var f forge.Forge @@ -83,11 +83,19 @@ func run(cmd *cobra.Command, _ []string) error { extraFiles := parseExtraFiles(flagExtraFiles) - updaters := []updater.NewUpdater{updater.Generic} - - if flagUpdatePackageJson { - logger.DebugContext(ctx, "package.json updater enabled") - updaters = append(updaters, updater.PackageJson) + updaterNames := parseUpdaters(flagUpdaters) + updaters := []updater.Updater{} + for _, name := range updaterNames { + switch name { + case "generic": + updaters = append(updaters, updater.Generic(extraFiles)) + case "changelog": + updaters = append(updaters, updater.Changelog()) + case "packagejson": + updaters = append(updaters, updater.PackageJson()) + default: + return fmt.Errorf("unknown updater: %s", name) + } } releaserPleaser := rp.New( @@ -122,3 +130,22 @@ func parseExtraFiles(input string) []string { return extraFiles } + +func parseUpdaters(input []string) []string { + names := []string{"changelog", "generic"} + + for _, u := range input { + if strings.HasPrefix(u, "-") { + name := u[1:] + names = slices.DeleteFunc(names, func(existingName string) bool { return existingName == name }) + } else { + names = append(names, u) + } + } + + // Make sure we only have unique updaters + slices.Sort(names) + names = slices.Compact(names) + + return names +} diff --git a/cmd/rp/cmd/run_test.go b/cmd/rp/cmd/run_test.go index d4cea7a..8354478 100644 --- a/cmd/rp/cmd/run_test.go +++ b/cmd/rp/cmd/run_test.go @@ -57,3 +57,43 @@ dir/Chart.yaml"`, }) } } + +func Test_parseUpdaters(t *testing.T) { + tests := []struct { + name string + input []string + want []string + }{ + { + name: "empty", + input: []string{}, + want: []string{"changelog", "generic"}, + }, + { + name: "remove defaults", + input: []string{"-changelog", "-generic"}, + want: []string{}, + }, + { + name: "remove unknown is ignored", + input: []string{"-fooo"}, + want: []string{"changelog", "generic"}, + }, + { + name: "add new entry", + input: []string{"bar"}, + want: []string{"bar", "changelog", "generic"}, + }, + { + name: "duplicates are removed", + input: []string{"bar", "bar", "changelog"}, + want: []string{"bar", "changelog", "generic"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseUpdaters(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b13bf24..0a22df1 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -25,6 +25,7 @@ - [Pull Request Options](reference/pr-options.md) - [GitHub Action](reference/github-action.md) - [GitLab CI/CD Component](reference/gitlab-cicd-component.md) +- [Updaters](reference/updaters.md) --- diff --git a/docs/guides/updating-arbitrary-files.md b/docs/guides/updating-arbitrary-files.md index d4b65bf..e7df9a5 100644 --- a/docs/guides/updating-arbitrary-files.md +++ b/docs/guides/updating-arbitrary-files.md @@ -10,7 +10,8 @@ In some situations it makes sense to have the current version committed in files ## Markers -The line that needs to be updated must have the marker `x-releaser-pleaser-version` somewhere after the version that should be updated. +The line that needs to be updated must have the marker +`x-releaser-pleaser-version` somewhere after the version that should be updated. For example: @@ -28,7 +29,8 @@ You need to tell `releaser-pleaser` which files it should update. This happens t ### GitHub Action -In the GitHub Action you can set the `extra-files` input with a list of the files. They need to be formatted as a single multi-line string with one file path per line: +In the GitHub Action you can set the +`extra-files` input with a list of the files. They need to be formatted as a single multi-line string with one file path per line: ```yaml jobs: @@ -44,7 +46,8 @@ jobs: ### GitLab CI/CD Component -In the GitLab CI/CD Component you can set the `extra-files` input with a list of files. They need to be formatted as a single multi-line string with one file path per line: +In the GitLab CI/CD Component you can set the +`extra-files` input with a list of files. They need to be formatted as a single multi-line string with one file path per line: ```yaml include: @@ -61,3 +64,4 @@ include: - **Reference** - [GitHub Action](../reference/github-action.md#inputs) - [GitLab CI/CD Component](../reference/gitlab-cicd-component.md#inputs) + - [Updaters](../reference/updaters.md#generic-updater) diff --git a/docs/reference/github-action.md b/docs/reference/github-action.md index 3a849dc..c5d595a 100644 --- a/docs/reference/github-action.md +++ b/docs/reference/github-action.md @@ -8,18 +8,20 @@ The action is available as `apricote/releaser-pleaser` on GitHub.com. The `apricote/releaser-pleaser` action is released together with `releaser-pleaser` and they share the version number. -The action does not support floating tags (e.g. `v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead: `apricote/releaser-pleaser@v0.2.0`. +The action does not support floating tags (e.g. +`v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead: +`apricote/releaser-pleaser@v0.2.0`. ## Inputs The following inputs are supported by the `apricote/releaser-pleaser` GitHub Action. -| Input | Description | Default | Example | -| --------------------- | :------------------------------------------------------ | --------------: | -------------------------------------------------------------------: | -| `branch` | This branch is used as the target for releases. | `main` | `master` | -| `token` | GitHub token for creating and updating release PRs | `$GITHUB_TOKEN` | `${{secrets.RELEASER_PLEASER_TOKEN}}` | -| `extra-files` | List of files that are scanned for version references. | `""` |
version/version.go
deploy/deployment.yaml
| -| `update-package-json` | Update version field in package.json file. | `false` | `true` | +| Input | Description | Default | Example | +|---------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------:|---------------------------------------------------------------------:| +| `branch` | This branch is used as the target for releases. | `main` | `master` | +| `token` | GitHub token for creating and updating release PRs | `$GITHUB_TOKEN` | `${{secrets.RELEASER_PLEASER_TOKEN}}` | +| `extra-files` | List of files that are scanned for version references by the generic updater. | `""` |
version/version.go
deploy/deployment.yaml
| +| `updaters` | List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic | `""` | `-generic,packagejson` | ## Outputs diff --git a/docs/reference/gitlab-cicd-component.md b/docs/reference/gitlab-cicd-component.md index 01a6fc2..3080fcf 100644 --- a/docs/reference/gitlab-cicd-component.md +++ b/docs/reference/gitlab-cicd-component.md @@ -18,11 +18,11 @@ The component does not support floating tags (e.g. The following inputs are supported by the component. -| Input | Description | Default | Example | -| ---------------------- | :-------------------------------------------------------- | ------: | -------------------------------------------------------------------: | -| `branch` | This branch is used as the target for releases. | `main` | `master` | -| `token` (**required**) | GitLab access token for creating and updating release PRs | | `$RELEASER_PLEASER_TOKEN` | -| `extra-files` | List of files that are scanned for version references. | `""` |
version/version.go
deploy/deployment.yaml
| -| `update-package-json` | Update version field in package.json file. | `false` | `true` | -| `stage` | Stage the job runs in. Must exists. | `build` | `test` | -| `needs` | Other jobs the releaser-pleaser job depends on. | `[]` |
- validate-foo
- prepare-bar
| +| Input | Description | Default | Example | +|------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------:|---------------------------------------------------------------------:| +| `branch` | This branch is used as the target for releases. | `main` | `master` | +| `token` (**required**) | GitLab access token for creating and updating release PRs | | `$RELEASER_PLEASER_TOKEN` | +| `extra-files` | List of files that are scanned for version references by the generic updater. | `""` |
version/version.go
deploy/deployment.yaml
| +| `updaters` | List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic | `""` | `-generic,packagejson` | +| `stage` | Stage the job runs in. Must exists. | `build` | `test` | +| `needs` | Other jobs the releaser-pleaser job depends on. | `[]` |
- validate-foo
- prepare-bar
| diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 543f970..8966f55 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -2,17 +2,21 @@ ### Changelog -The Changelog is a file in the repository (`CHANGELOG.md`) that contains the [Release Notes](#release-notes) for every release of that repository. Usually, new releases are added at the top of the file. +The Changelog is a file in the repository ( +`CHANGELOG.md`) that contains the [Release Notes](#release-notes) for every release of that repository. Usually, new releases are added at the top of the file. ### Conventional Commits -[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is a specification for commit messages. It is the only supported commit message schema in `releaser-pleaser`. Follow the link to learn more. +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is a specification for commit messages. It is the only supported commit message schema in +`releaser-pleaser`. Follow the link to learn more. ### Forge -A **forge** is a web-based collaborative software platform for both developing and sharing computer applications.[^wp-forge] +A **forge +** is a web-based collaborative software platform for both developing and sharing computer applications.[^wp-forge] -Right now only **GitHub** is supported. We plan to support **GitLab** in the future ([#4](https://github.com/apricote/releaser-pleaser/issues/4)). For other forges like Forgejo or Gitea, please open an issue and submit a pull request. +Right now only **GitHub** is supported. We plan to support **GitLab +** in the future ([#4](https://github.com/apricote/releaser-pleaser/issues/4)). For other forges like Forgejo or Gitea, please open an issue and submit a pull request. [^wp-forge]: Quote from [Wikipedia "Forge (software)"]() @@ -24,7 +28,8 @@ In `releaser-pleaser` Markdown is used for most texts. ### Pre-release -Pre-releases are a concept of [SemVer](#semantic-versioning-semver). They follow the normal versioning schema but use a suffix out of `-alpha.X`, `-beta.X` and `-rc.X`. +Pre-releases are a concept of [SemVer](#semantic-versioning-semver). They follow the normal versioning schema but use a suffix out of +`-alpha.X`, `-beta.X` and `-rc.X`. Pre-releases are not considered "stable" and are usually not recommended for most users. @@ -32,7 +37,9 @@ Learn more in the [Pre-releases](../guides/pre-releases.md) guide. ### Release Pull Request -A Release Pull Request is opened by `releaser-pleaser` whenever it finds releasable commits in your project. It proposes a new version number and the Changelog. Once it is merged, `releaser-pleaser` creates a matching release. +A Release Pull Request is opened by +`releaser-pleaser` whenever it finds releasable commits in your project. It proposes a new version number and the Changelog. Once it is merged, +`releaser-pleaser` creates a matching release. Learn more in the [Release Pull Request](../explanation/release-pr.md) explanation. @@ -44,4 +51,11 @@ Learn more in the [Release Notes customization](../guides/release-notes.md) guid ### Semantic Versioning (SemVer) -[Semantic Versioning](https://semver.org/) is a specification for version numbers. It is the only supported versioning schema in `releaser-pleaser`. Follow the link to learn more. +[Semantic Versioning](https://semver.org/) is a specification for version numbers. It is the only supported versioning schema in +`releaser-pleaser`. Follow the link to learn more. + +### Updater + +Updaters can update or create files that will be included in [Release Pull Request](#release-pull-request). Examples of Updaters are +`changelog` for `CHANGELOG.md`, `generic` that can update arbitrary files and +`packagejson` that knows how to update Node.JS `package.json` files. \ No newline at end of file diff --git a/docs/reference/updaters.md b/docs/reference/updaters.md new file mode 100644 index 0000000..6bafff4 --- /dev/null +++ b/docs/reference/updaters.md @@ -0,0 +1,33 @@ +# Updaters + +There are different updater for different purposes available. + +They each have a name and may be enabled by default. You can configure which updaters are used through the +`updaters` input on GitHub Actions and GitLab CI/CD. This is a comma-delimited list of updaters that should be enabled, for updaters that are enabled by default you can remove them by adding a minus before its name: + +``` +updaters: -generic,packagejson +``` + +## Changelog + +- **Name**: `changelog` +- **Default**: enabled + +This updater creates the `CHANGELOG.md` file and adds new release notes to it. + +## Generic Updater + +- **Name**: `generic` +- **Default**: enabled + +This updater can update any file and only needs a marker on the line. It is enabled by default. + +Learn more about this updater in ["Updating arbitrary files"](../guides/updating-arbitrary-files.md). + +## Node.js `package.json` Updater + +- **Name**: `packagejson` +- **Default**: disabled + +This updater can update the `version` field in Node.js `package.json` files. The updater is disabled by default. diff --git a/internal/git/git.go b/internal/git/git.go index b9c750e..ad5c0a3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -6,7 +6,6 @@ import ( "io" "log/slog" "os" - "path/filepath" "time" "github.com/go-git/go-git/v5" @@ -14,8 +13,6 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport" - - "github.com/apricote/releaser-pleaser/internal/updater" ) const ( @@ -120,7 +117,7 @@ func (r *Repository) Checkout(_ context.Context, branch string) error { return nil } -func (r *Repository) UpdateFile(_ context.Context, path string, create bool, updaters []updater.Updater) error { +func (r *Repository) UpdateFile(_ context.Context, path string, create bool, updateHook func(string) (string, error)) error { worktree, err := r.r.Worktree() if err != nil { return err @@ -142,13 +139,9 @@ func (r *Repository) UpdateFile(_ context.Context, path string, create bool, upd return err } - updatedContent := string(content) - - for _, update := range updaters { - updatedContent, err = update(updatedContent, filepath.Base(path)) - if err != nil { - return fmt.Errorf("failed to run updater on file %s", path) - } + updatedContent, err := updateHook(string(content)) + if err != nil { + return fmt.Errorf("failed to run update hook on file %s", path) } err = file.Truncate(0) diff --git a/internal/updater/changelog.go b/internal/updater/changelog.go index 8d6d68c..a7c7506 100644 --- a/internal/updater/changelog.go +++ b/internal/updater/changelog.go @@ -14,8 +14,23 @@ var ( ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`) ) -func Changelog(info ReleaseInfo) Updater { - return func(content string, filename string) (string, error) { +func Changelog() Updater { + return changelog{} +} + +type changelog struct { +} + +func (c changelog) Files() []string { + return []string{ChangelogFile} +} + +func (c changelog) CreateNewFiles() bool { + return true +} + +func (c changelog) Update(info ReleaseInfo) func(content string) (string, error) { + return func(content string) (string, error) { headerIndex := ChangelogUpdaterHeaderRegex.FindStringIndex(content) if headerIndex == nil && len(content) != 0 { return "", fmt.Errorf("unexpected format of CHANGELOG.md, header does not match") diff --git a/internal/updater/changelog_test.go b/internal/updater/changelog_test.go index 35878b9..c0becb5 100644 --- a/internal/updater/changelog_test.go +++ b/internal/updater/changelog_test.go @@ -6,15 +6,22 @@ import ( "github.com/stretchr/testify/assert" ) -func TestChangelogUpdater_UpdateContent(t *testing.T) { +func TestChangelogUpdater_Files(t *testing.T) { + assert.Equal(t, []string{"CHANGELOG.md"}, Changelog().Files()) +} + +func TestChangelogUpdater_CreateNewFiles(t *testing.T) { + assert.True(t, Changelog().CreateNewFiles()) +} + +func TestChangelogUpdater_Update(t *testing.T) { tests := []updaterTestCase{ { - name: "empty file", - content: "", - filename: "CHANGELOG.md", - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"}, - want: "# Changelog\n\n## v1.0.0\n", - wantErr: assert.NoError, + name: "empty file", + content: "", + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"}, + want: "# Changelog\n\n## v1.0.0\n", + wantErr: assert.NoError, }, { name: "well-formatted changelog", @@ -28,8 +35,7 @@ func TestChangelogUpdater_UpdateContent(t *testing.T) { ### Bazuuum `, - filename: "CHANGELOG.md", - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, want: `# Changelog ## v1.0.0 @@ -47,17 +53,16 @@ func TestChangelogUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "error on invalid header", - content: "What even is this file?", - filename: "CHANGELOG.md", - info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, - want: "", - wantErr: assert.Error, + name: "error on invalid header", + content: "What even is this file?", + info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"}, + want: "", + wantErr: assert.Error, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - runUpdaterTest(t, Changelog, tt) + runUpdaterTest(t, Changelog(), tt) }) } } diff --git a/internal/updater/generic.go b/internal/updater/generic.go index 1883c1a..11b21a4 100644 --- a/internal/updater/generic.go +++ b/internal/updater/generic.go @@ -7,8 +7,26 @@ import ( var GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`) -func Generic(info ReleaseInfo) Updater { - return func(content string, filename string) (string, error) { +func Generic(files []string) Updater { + return generic{ + files: files, + } +} + +type generic struct { + files []string +} + +func (g generic) Files() []string { + return g.files +} + +func (g generic) CreateNewFiles() bool { + return false +} + +func (g generic) Update(info ReleaseInfo) func(content string) (string, error) { + return func(content string) (string, error) { // We strip the "v" prefix to avoid adding/removing it from the users input. version := strings.TrimPrefix(info.Version, "v") diff --git a/internal/updater/generic_test.go b/internal/updater/generic_test.go index 4cc8952..7c007a4 100644 --- a/internal/updater/generic_test.go +++ b/internal/updater/generic_test.go @@ -6,12 +6,19 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGenericUpdater_UpdateContent(t *testing.T) { +func TestGenericUpdater_Files(t *testing.T) { + assert.Equal(t, []string{"foo.bar", "version.txt"}, Generic([]string{"foo.bar", "version.txt"}).Files()) +} + +func TestGenericUpdater_CreateNewFiles(t *testing.T) { + assert.False(t, Generic([]string{}).CreateNewFiles()) +} + +func TestGenericUpdater_Update(t *testing.T) { tests := []updaterTestCase{ { - name: "single line", - content: "v1.0.0 // x-releaser-pleaser-version", - filename: "version.txt", + name: "single line", + content: "v1.0.0 // x-releaser-pleaser-version", info: ReleaseInfo{ Version: "v1.2.0", }, @@ -19,9 +26,8 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "multiline line", - content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n", - filename: "version.txt", + name: "multiline line", + content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n", info: ReleaseInfo{ Version: "v1.2.0", }, @@ -29,9 +35,8 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "invalid existing version", - content: "1.0 // x-releaser-pleaser-version", - filename: "version.txt", + name: "invalid existing version", + content: "1.0 // x-releaser-pleaser-version", info: ReleaseInfo{ Version: "v1.2.0", }, @@ -39,9 +44,8 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { wantErr: assert.NoError, }, { - name: "complicated line", - content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar", - filename: "version.txt", + name: "complicated line", + content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar", info: ReleaseInfo{ Version: "v1.2.0", }, @@ -51,7 +55,7 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - runUpdaterTest(t, Generic, tt) + runUpdaterTest(t, Generic([]string{"version.txt"}), tt) }) } } diff --git a/internal/updater/packagejson.go b/internal/updater/packagejson.go index 58f2cc3..0fcb8a5 100644 --- a/internal/updater/packagejson.go +++ b/internal/updater/packagejson.go @@ -6,11 +6,22 @@ import ( ) // PackageJson creates an updater that modifies the version field in package.json files -func PackageJson(info ReleaseInfo) Updater { - return func(content string, filename string) (string, error) { - if filename != "package.json" { - return content, nil // No update needed for non-package.json files - } +func PackageJson() Updater { + return packagejson{} +} + +type packagejson struct{} + +func (p packagejson) Files() []string { + return []string{"package.json"} +} + +func (p packagejson) CreateNewFiles() bool { + return false +} + +func (p packagejson) Update(info ReleaseInfo) func(content string) (string, error) { + return func(content string) (string, error) { // We strip the "v" prefix to match npm versioning convention version := strings.TrimPrefix(info.Version, "v") @@ -23,8 +34,6 @@ func PackageJson(info ReleaseInfo) Updater { } // Replace the version value while preserving the original formatting - updatedContent := versionRegex.ReplaceAllString(content, `${1}"`+version+`"`) - - return updatedContent, nil + return versionRegex.ReplaceAllString(content, `${1}"`+version+`"`), nil } } diff --git a/internal/updater/packagejson_test.go b/internal/updater/packagejson_test.go index 4fd196e..9bff8b7 100644 --- a/internal/updater/packagejson_test.go +++ b/internal/updater/packagejson_test.go @@ -1,18 +1,24 @@ package updater import ( - "fmt" "testing" "github.com/stretchr/testify/assert" ) -func TestPackageJsonUpdater(t *testing.T) { +func TestPackageJsonUpdater_Files(t *testing.T) { + assert.Equal(t, []string{"package.json"}, PackageJson().Files()) +} + +func TestPackageJsonUpdater_CreateNewFiles(t *testing.T) { + assert.False(t, PackageJson().CreateNewFiles()) +} + +func TestPackageJsonUpdater_Update(t *testing.T) { tests := []updaterTestCase{ { - name: "simple package.json", - content: `{"name":"test","version":"1.0.0"}`, - filename: "package.json", + name: "simple package.json", + content: `{"name":"test","version":"1.0.0"}`, info: ReleaseInfo{ Version: "v2.0.5", }, @@ -20,19 +26,8 @@ func TestPackageJsonUpdater(t *testing.T) { wantErr: assert.NoError, }, { - name: "simple package.json, wrong name", - content: `{"name":"test","version":"1.0.0"}`, - filename: "nopackage.json", - info: ReleaseInfo{ - Version: "v2.0.5", - }, - want: `{"name":"test","version":"1.0.0"}`, - wantErr: assert.NoError, - }, - { - name: "complex package.json", - content: "{\n \"name\": \"test\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}", - filename: "package.json", + name: "complex package.json", + content: "{\n \"name\": \"test\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}", info: ReleaseInfo{ Version: "v2.0.0", }, @@ -40,9 +35,8 @@ func TestPackageJsonUpdater(t *testing.T) { wantErr: assert.NoError, }, { - name: "invalid json", - content: `not json`, - filename: "package.json", + name: "invalid json", + content: `not json`, info: ReleaseInfo{ Version: "v2.0.0", }, @@ -50,9 +44,8 @@ func TestPackageJsonUpdater(t *testing.T) { wantErr: assert.NoError, }, { - name: "json without version", - content: `{"name":"test"}`, - filename: "package.json", + name: "json without version", + content: `{"name":"test"}`, info: ReleaseInfo{ Version: "v2.0.0", }, @@ -63,8 +56,7 @@ func TestPackageJsonUpdater(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - fmt.Println("Running updater test for PackageJson") - runUpdaterTest(t, PackageJson, tt) + runUpdaterTest(t, PackageJson(), tt) }) } } diff --git a/internal/updater/updater.go b/internal/updater/updater.go index f5fd677..6e27f37 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -5,7 +5,11 @@ type ReleaseInfo struct { ChangelogEntry string } -type Updater func(content string, filename string) (string, error) +type Updater interface { + Files() []string + CreateNewFiles() bool + Update(info ReleaseInfo) func(content string) (string, error) +} type NewUpdater func(ReleaseInfo) Updater diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 17162ef..5a90936 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -8,20 +8,19 @@ import ( ) type updaterTestCase struct { - name string - content string - filename string - info ReleaseInfo - want string - wantErr assert.ErrorAssertionFunc + name string + content string + info ReleaseInfo + want string + wantErr assert.ErrorAssertionFunc } -func runUpdaterTest(t *testing.T, constructor NewUpdater, tt updaterTestCase) { +func runUpdaterTest(t *testing.T, u Updater, tt updaterTestCase) { t.Helper() - got, err := constructor(tt.info)(tt.content, tt.filename) - if !tt.wantErr(t, err, fmt.Sprintf("Updater(%v, %v, %v)", tt.content, tt.filename, tt.info)) { + got, err := u.Update(tt.info)(tt.content) + if !tt.wantErr(t, err, fmt.Sprintf("Updater(%v, %v)", tt.content, tt.info)) { return } - assert.Equalf(t, tt.want, got, "Updater(%v, %v, %v)", tt.content, tt.filename, tt.info) + assert.Equalf(t, tt.want, got, "Updater(%v, %v)", tt.content, tt.info) } diff --git a/releaserpleaser.go b/releaserpleaser.go index a09aefe..b72b85f 100644 --- a/releaserpleaser.go +++ b/releaserpleaser.go @@ -34,10 +34,10 @@ type ReleaserPleaser struct { commitParser commitparser.CommitParser versioning versioning.Strategy extraFiles []string - updaters []updater.NewUpdater + updaters []updater.Updater } -func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.NewUpdater) *ReleaserPleaser { +func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.Updater) *ReleaserPleaser { return &ReleaserPleaser{ forge: forge, logger: logger, @@ -281,16 +281,12 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { // Info for updaters info := updater.ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry} - err = repo.UpdateFile(ctx, updater.ChangelogFile, true, updater.WithInfo(info, updater.Changelog)) - if err != nil { - return fmt.Errorf("failed to update changelog file: %w", err) - } - - for _, path := range rp.extraFiles { - // TODO: Check for missing files - err = repo.UpdateFile(ctx, path, false, updater.WithInfo(info, rp.updaters...)) - if err != nil { - return fmt.Errorf("failed to run file updater: %w", err) + for _, u := range rp.updaters { + for _, file := range u.Files() { + err = repo.UpdateFile(ctx, file, u.CreateNewFiles(), u.Update(info)) + if err != nil { + return fmt.Errorf("failed to run updater %T: %w", u, err) + } } } diff --git a/templates/run.yml b/templates/run.yml index 66037b1..79459d9 100644 --- a/templates/run.yml +++ b/templates/run.yml @@ -9,12 +9,12 @@ spec: description: "GitLab token for creating and updating release MRs." extra-files: - description: 'List of files that are scanned for version references.' + description: 'List of files that are scanned for version references by the generic updater.' default: "" - update-package-json: - description: 'Update version field in package.json file.' - default: "false" + updaters: + description: "List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic" + default: "" stage: default: build @@ -54,4 +54,4 @@ releaser-pleaser: --forge=gitlab \ --branch=$[[ inputs.branch ]] \ --extra-files="$[[ inputs.extra-files ]]" \ - $([[ inputs.update-package-json == "true" ]] && echo "--update-package-json" || echo "") + --updaters="$[[ inputs.updaters ]]" From 5306e2dd35de78b281e3307bc0864ba768f015c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 23 Aug 2025 22:38:24 +0200 Subject: [PATCH 05/20] fix: filter out empty updaters in input (#235) --- cmd/rp/cmd/run.go | 4 ++++ cmd/rp/cmd/run_test.go | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index 202ba3d..a70e915 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -135,6 +135,10 @@ func parseUpdaters(input []string) []string { names := []string{"changelog", "generic"} for _, u := range input { + if u == "" { + continue + } + if strings.HasPrefix(u, "-") { name := u[1:] names = slices.DeleteFunc(names, func(existingName string) bool { return existingName == name }) diff --git a/cmd/rp/cmd/run_test.go b/cmd/rp/cmd/run_test.go index 8354478..4c6ceff 100644 --- a/cmd/rp/cmd/run_test.go +++ b/cmd/rp/cmd/run_test.go @@ -89,6 +89,11 @@ func Test_parseUpdaters(t *testing.T) { input: []string{"bar", "bar", "changelog"}, want: []string{"bar", "changelog", "generic"}, }, + { + name: "remove empty entries", + input: []string{""}, + want: []string{"changelog", "generic"}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { From 48b1894cac4aca3f04e6985a3d05cc77d5e6efb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 23 Aug 2025 22:40:28 +0200 Subject: [PATCH 06/20] feat: highlight breaking changes in release notes (#234) Add a `**BREAKING**` prefix to any entries in the changelog that are marked as breaking changes. Closes #225 --- internal/changelog/changelog.md.tpl | 2 +- internal/changelog/changelog_test.go | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/changelog/changelog.md.tpl b/internal/changelog/changelog.md.tpl index 50907eb..0a73dd5 100644 --- a/internal/changelog/changelog.md.tpl +++ b/internal/changelog/changelog.md.tpl @@ -1,5 +1,5 @@ {{define "entry" -}} -- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}} +- {{ if .BreakingChange}}**BREAKING**: {{end}}{{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}} {{ end }} {{- if not .Formatting.HideVersionTitle }} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go index a969730..e4fe52f 100644 --- a/internal/changelog/changelog_test.go +++ b/internal/changelog/changelog_test.go @@ -54,6 +54,23 @@ func Test_NewChangelogEntry(t *testing.T) { want: "## [1.0.0](https://example.com/1.0.0)\n\n### Features\n\n- Foobar!\n", wantErr: assert.NoError, }, + { + name: "single breaking change", + args: args{ + analyzedCommits: []commitparser.AnalyzedCommit{ + { + Commit: git.Commit{}, + Type: "feat", + Description: "Foobar!", + BreakingChange: true, + }, + }, + version: "1.0.0", + link: "https://example.com/1.0.0", + }, + want: "## [1.0.0](https://example.com/1.0.0)\n\n### Features\n\n- **BREAKING**: Foobar!\n", + wantErr: assert.NoError, + }, { name: "single fix", args: args{ From d259921215a04117e6b97124b180d051fbf7b946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 23 Aug 2025 23:00:52 +0200 Subject: [PATCH 07/20] fix(github): duplicate release pr when process is stopped at wrong moment (#236) In a timing issue, the release pull request may be created but the releaser-pleaser labels not added. On the next run releaser-pleaser then creates a second release pull request. We try to reduce the chance of this happening by checking the context cancellation at the top, and if its not cancelled we run both API requests without passing along any cancellations from the parent context. Closes #215 --- internal/forge/github/github.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 3bff3e6..f950add 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -296,6 +296,13 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*rele } func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { + // If the Pull Request is created without the labels releaser-pleaser will create a new PR in the run. The user may merge both and have duplicate entries in the changelog. + // We try to avoid this situation by checking for a cancelled context first, and then running both API calls without passing along any cancellations. + if ctx.Err() != nil { + return ctx.Err() + } + ctx = context.WithoutCancel(ctx) + ghPR, _, err := g.client.PullRequests.Create( ctx, g.options.Owner, g.options.Repo, &github.NewPullRequest{ @@ -309,7 +316,6 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePul return err } - // TODO: String ID? pr.ID = ghPR.GetNumber() err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels) From b3cb9e128ccc8d968949dc34fdee8a61d21b834a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 23 Aug 2025 23:05:30 +0200 Subject: [PATCH 08/20] chore(main): release v0.7.0 (#232) --- CHANGELOG.md | 22 ++++++++++++++++++++++ action.yml | 2 +- templates/run.yml | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb192f..7038de1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## [v0.7.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.7.0) + +### Highlights :sparkles: + +#### Update version in `package.json` + +Thanks to @Mattzi it is now possible to use `releaser-pleaser` in Javascript/Node.js projects with a `package.json` file. + +You can enable this with the option `updaters: packagejson` in the GitHub Actions / GitLab CI/CD config. + +All updaters, including the defaults `changelog` and `generic` can now be enabled and disabled through this field. You can find a full list in the [documentation](https://apricote.github.io/releaser-pleaser/reference/updaters.html). + +### Features + +- add updater for package.json (#213) +- highlight breaking changes in release notes (#234) + +### Bug Fixes + +- filter out empty updaters in input (#235) +- **github**: duplicate release pr when process is stopped at wrong moment (#236) + ## [v0.6.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.6.1) ### Bug Fixes diff --git a/action.yml b/action.yml index 651ca09..a6de20c 100644 --- a/action.yml +++ b/action.yml @@ -25,7 +25,7 @@ inputs: outputs: { } runs: using: 'docker' - image: docker://ghcr.io/apricote/releaser-pleaser:v0.6.1 # x-releaser-pleaser-version + image: docker://ghcr.io/apricote/releaser-pleaser:v0.7.0 # x-releaser-pleaser-version args: - run - --forge=github diff --git a/templates/run.yml b/templates/run.yml index 79459d9..0d71f84 100644 --- a/templates/run.yml +++ b/templates/run.yml @@ -44,7 +44,7 @@ releaser-pleaser: resource_group: releaser-pleaser image: - name: ghcr.io/apricote/releaser-pleaser:v0.6.1 # x-releaser-pleaser-version + name: ghcr.io/apricote/releaser-pleaser:v0.7.0 # x-releaser-pleaser-version entrypoint: [ "" ] variables: GITLAB_TOKEN: $[[ inputs.token ]] From c768260a2ecd56c8a8f397243ae56b76f1ec2dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 24 Aug 2025 15:49:23 +0200 Subject: [PATCH 09/20] chore: use mise to install all tools in CI and locally (#237) Makes sure that I have the same versions locally as CI --- .github/workflows/ci.yaml | 19 +++++-------------- .github/workflows/release.yaml | 6 +----- .github/workflows/releaser-pleaser.yaml | 10 +++------- mise.toml | 10 ++++++++++ 4 files changed, 19 insertions(+), 26 deletions(-) create mode 100644 mise.toml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9d137dc..75e33e4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,7 +2,7 @@ name: ci on: push: - branches: [main] + branches: [ main ] pull_request: jobs: @@ -12,15 +12,12 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 - name: Run golangci-lint uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 with: - version: v2.4.0 # renovate: datasource=github-releases depName=golangci/golangci-lint + install-mode: none args: --timeout 5m test: @@ -29,10 +26,7 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 - name: Run tests run: go test -v -race -coverpkg=./... -coverprofile=coverage.txt ./... @@ -48,10 +42,7 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 - name: Run go mod tidy run: go mod tidy diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index bcc947d..1374fd6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,10 +16,6 @@ jobs: - name: Checkout uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 - - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - run: ko build --bare --tags ${{ github.ref_name }} github.com/apricote/releaser-pleaser/cmd/rp diff --git a/.github/workflows/releaser-pleaser.yaml b/.github/workflows/releaser-pleaser.yaml index 6e79306..5536579 100644 --- a/.github/workflows/releaser-pleaser.yaml +++ b/.github/workflows/releaser-pleaser.yaml @@ -2,7 +2,7 @@ name: releaser-pleaser on: push: - branches: [main] + branches: [ main ] # Using pull_request_target to avoid tainting the actual release PR with code from open feature pull requests pull_request_target: types: @@ -17,7 +17,7 @@ concurrency: group: releaser-pleaser cancel-in-progress: true -permissions: {} +permissions: { } jobs: releaser-pleaser: @@ -29,15 +29,11 @@ jobs: with: ref: main - - name: Set up Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 # Build container image from current commit and replace image ref in `action.yml` # Without this, any new flags in `action.yml` would break the job in this repository until the new # version is released. But a new version can only be released if this job works. - - uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9 - run: ko build --bare --local --platform linux/amd64 --tags ci github.com/apricote/releaser-pleaser/cmd/rp - run: mkdir -p .github/actions/releaser-pleaser diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..d520f6f --- /dev/null +++ b/mise.toml @@ -0,0 +1,10 @@ +[tools] +go = "1.25.0" +golangci-lint = "v2.4.0" +goreleaser = "v2.9.0" +"github:rust-lang/mdBook" = "v0.4.52" +"github:ko-build/ko" = "v0.18.0" + +[settings] +# Experimental features are needed for the Go backend +experimental = true From 5b5b29c0b58cb655504d86f01bd925cdc7544db4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 13:50:59 +0000 Subject: [PATCH 10/20] deps: update dependency go to v1.25.0 (#222) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 41f54c3..997a693 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,7 @@ module github.com/apricote/releaser-pleaser go 1.23.2 -toolchain go1.24.6 +toolchain go1.25.0 require ( github.com/blang/semver/v4 v4.0.0 From e6503da93a115cd4a8f8b242cab19fce8c206880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 24 Aug 2025 16:44:05 +0200 Subject: [PATCH 11/20] refactor(cmd): use factories instead of global cobra command structs (#238) This enables us to create new commands for e2e tests. --- cmd/rp/cmd/root.go | 39 ++++------ cmd/rp/cmd/run.go | 176 ++++++++++++++++++++++---------------------- cmd/rp/main.go | 1 + internal/log/log.go | 23 ++++++ 4 files changed, 128 insertions(+), 111 deletions(-) create mode 100644 internal/log/log.go diff --git a/cmd/rp/cmd/root.go b/cmd/rp/cmd/root.go index 2799e6d..f2dd180 100644 --- a/cmd/rp/cmd/root.go +++ b/cmd/rp/cmd/root.go @@ -7,21 +7,23 @@ import ( "os/signal" "runtime/debug" "syscall" - "time" - "github.com/lmittmann/tint" "github.com/spf13/cobra" ) -var logger *slog.Logger +func NewRootCmd() *cobra.Command { + var cmd = &cobra.Command{ + Use: "rp", + Short: "", + Long: ``, + Version: version(), + SilenceUsage: true, // Makes it harder to find the actual error + SilenceErrors: true, // We log manually with slog + } -var rootCmd = &cobra.Command{ - Use: "rp", - Short: "", - Long: ``, - Version: version(), - SilenceUsage: true, // Makes it harder to find the actual error - SilenceErrors: true, // We log manually with slog + cmd.AddCommand(newRunCommand()) + + return cmd } func version() string { @@ -66,24 +68,13 @@ func Execute() { // Make sure to stop listening on signals after receiving the first signal to hand control of the signal back // to the runtime. The Go runtime implements a "force shutdown" if the signal is received again. <-ctx.Done() - logger.InfoContext(ctx, "Received shutdown signal, stopping...") + slog.InfoContext(ctx, "Received shutdown signal, stopping...") stop() }() - err := rootCmd.ExecuteContext(ctx) + err := NewRootCmd().ExecuteContext(ctx) if err != nil { - logger.ErrorContext(ctx, err.Error()) + slog.ErrorContext(ctx, err.Error()) os.Exit(1) } } - -func init() { - logger = slog.New( - tint.NewHandler(os.Stderr, &tint.Options{ - Level: slog.LevelDebug, - TimeFormat: time.RFC3339, - }), - ) - - slog.SetDefault(logger) -} diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index a70e915..d6bbe4b 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -2,6 +2,7 @@ package cmd import ( "fmt" + "log/slog" "slices" "strings" @@ -12,103 +13,104 @@ import ( "github.com/apricote/releaser-pleaser/internal/forge" "github.com/apricote/releaser-pleaser/internal/forge/github" "github.com/apricote/releaser-pleaser/internal/forge/gitlab" + "github.com/apricote/releaser-pleaser/internal/log" "github.com/apricote/releaser-pleaser/internal/updater" "github.com/apricote/releaser-pleaser/internal/versioning" ) -var runCmd = &cobra.Command{ - Use: "run", - RunE: run, -} - -var ( - flagForge string - flagBranch string - flagOwner string - flagRepo string - flagExtraFiles string - flagUpdaters []string -) - -func init() { - rootCmd.AddCommand(runCmd) - runCmd.PersistentFlags().StringVar(&flagForge, "forge", "", "") - runCmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") - runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") - runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") - runCmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") - runCmd.PersistentFlags().StringSliceVar(&flagUpdaters, "updaters", []string{}, "") -} - -func run(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - var err error - - logger.DebugContext(ctx, "run called", - "forge", flagForge, - "branch", flagBranch, - "owner", flagOwner, - "repo", flagRepo, +func newRunCommand() *cobra.Command { + var ( + flagForge string + flagBranch string + flagOwner string + flagRepo string + flagExtraFiles string + flagUpdaters []string ) - var f forge.Forge + var cmd = &cobra.Command{ + Use: "run", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + logger := log.GetLogger(cmd.ErrOrStderr()) - forgeOptions := forge.Options{ - Repository: flagRepo, - BaseBranch: flagBranch, + var err error + + logger.DebugContext(ctx, "run called", + "forge", flagForge, + "branch", flagBranch, + "owner", flagOwner, + "repo", flagRepo, + ) + + var f forge.Forge + + forgeOptions := forge.Options{ + Repository: flagRepo, + BaseBranch: flagBranch, + } + + switch flagForge { + case "gitlab": + logger.DebugContext(ctx, "using forge GitLab") + f, err = gitlab.New(logger, &gitlab.Options{ + Options: forgeOptions, + Path: fmt.Sprintf("%s/%s", flagOwner, flagRepo), + }) + if err != nil { + slog.ErrorContext(ctx, "failed to create client", "err", err) + return fmt.Errorf("failed to create gitlab client: %w", err) + } + case "github": + logger.DebugContext(ctx, "using forge GitHub") + f = github.New(logger, &github.Options{ + Options: forgeOptions, + Owner: flagOwner, + Repo: flagRepo, + }) + default: + return fmt.Errorf("unknown --forge: %s", flagForge) + } + + extraFiles := parseExtraFiles(flagExtraFiles) + + updaterNames := parseUpdaters(flagUpdaters) + updaters := []updater.Updater{} + for _, name := range updaterNames { + switch name { + case "generic": + updaters = append(updaters, updater.Generic(extraFiles)) + case "changelog": + updaters = append(updaters, updater.Changelog()) + case "packagejson": + updaters = append(updaters, updater.PackageJson()) + default: + return fmt.Errorf("unknown updater: %s", name) + } + } + + releaserPleaser := rp.New( + f, + logger, + flagBranch, + conventionalcommits.NewParser(logger), + versioning.SemVer, + extraFiles, + updaters, + ) + + return releaserPleaser.Run(ctx) + }, } - switch flagForge { - case "gitlab": - logger.DebugContext(ctx, "using forge GitLab") - f, err = gitlab.New(logger, &gitlab.Options{ - Options: forgeOptions, - Path: fmt.Sprintf("%s/%s", flagOwner, flagRepo), - }) - if err != nil { - logger.ErrorContext(ctx, "failed to create client", "err", err) - return fmt.Errorf("failed to create gitlab client: %w", err) - } - case "github": - logger.DebugContext(ctx, "using forge GitHub") - f = github.New(logger, &github.Options{ - Options: forgeOptions, - Owner: flagOwner, - Repo: flagRepo, - }) - default: - return fmt.Errorf("unknown --forge: %s", flagForge) - } + cmd.PersistentFlags().StringVar(&flagForge, "forge", "", "") + cmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") + cmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") + cmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") + cmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") + cmd.PersistentFlags().StringSliceVar(&flagUpdaters, "updaters", []string{}, "") - extraFiles := parseExtraFiles(flagExtraFiles) - - updaterNames := parseUpdaters(flagUpdaters) - updaters := []updater.Updater{} - for _, name := range updaterNames { - switch name { - case "generic": - updaters = append(updaters, updater.Generic(extraFiles)) - case "changelog": - updaters = append(updaters, updater.Changelog()) - case "packagejson": - updaters = append(updaters, updater.PackageJson()) - default: - return fmt.Errorf("unknown updater: %s", name) - } - } - - releaserPleaser := rp.New( - f, - logger, - flagBranch, - conventionalcommits.NewParser(logger), - versioning.SemVer, - extraFiles, - updaters, - ) - - return releaserPleaser.Run(ctx) + return cmd } func parseExtraFiles(input string) []string { diff --git a/cmd/rp/main.go b/cmd/rp/main.go index 462629b..734709c 100644 --- a/cmd/rp/main.go +++ b/cmd/rp/main.go @@ -2,6 +2,7 @@ package main import ( "github.com/apricote/releaser-pleaser/cmd/rp/cmd" + _ "github.com/apricote/releaser-pleaser/internal/log" ) func main() { diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..32219fd --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,23 @@ +package log + +import ( + "io" + "log/slog" + "os" + "time" + + "github.com/lmittmann/tint" +) + +func GetLogger(w io.Writer) *slog.Logger { + return slog.New( + tint.NewHandler(w, &tint.Options{ + Level: slog.LevelDebug, + TimeFormat: time.RFC3339, + }), + ) +} + +func init() { + slog.SetDefault(GetLogger(os.Stderr)) +} From e6c8f3f93b8a6387b6706a3dd6596498178f3c12 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 16:45:41 +0200 Subject: [PATCH 12/20] deps: update actions/upload-pages-artifact action to v4 (#240) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/docs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 912615e..0a411be 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -29,7 +29,7 @@ jobs: uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Upload artifact - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 with: # Upload entire repository path: "docs/book" From 16ba2c6b090788850ec7d882b7e265c9d215c2d9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 16:46:16 +0200 Subject: [PATCH 13/20] deps: update module github.com/google/go-github/v72 to v74 (#241) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- internal/forge/github/github.go | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 997a693..84390a0 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/blang/semver/v4 v4.0.0 github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git/v5 v5.16.2 - github.com/google/go-github/v72 v72.0.0 + github.com/google/go-github/v74 v74.0.0 github.com/leodido/go-conventionalcommits v0.12.0 github.com/lmittmann/tint v1.1.2 github.com/spf13/cobra v1.9.1 diff --git a/go.sum b/go.sum index bc93f70..283db4c 100644 --- a/go.sum +++ b/go.sum @@ -40,8 +40,8 @@ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUv github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= -github.com/google/go-github/v72 v72.0.0/go.mod h1:WWtw8GMRiL62mvIquf1kO3onRHeWWKmK01qdCY8c5fg= +github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= +github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= 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/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index f950add..d01e529 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -12,7 +12,7 @@ import ( "github.com/blang/semver/v4" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/google/go-github/v72/github" + "github.com/google/go-github/v74/github" "github.com/apricote/releaser-pleaser/internal/forge" "github.com/apricote/releaser-pleaser/internal/git" From 563885899c914d62f670ac71b6c7427e8795b149 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 24 Aug 2025 16:46:49 +0200 Subject: [PATCH 14/20] deps: update actions/checkout action to v5 (#239) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/ci.yaml | 6 +++--- .github/workflows/docs.yaml | 2 +- .github/workflows/mirror.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/releaser-pleaser.yaml | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 75e33e4..cb821e6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 @@ -24,7 +24,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 @@ -40,7 +40,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 0a411be..4e728bc 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -13,7 +13,7 @@ jobs: id-token: write # To update the deployment status steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: "true" diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index d7feadb..940d149 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -11,7 +11,7 @@ jobs: REMOTE: mirror steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: # Need all to fetch all tags so we can push them fetch-depth: 0 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1374fd6..7a0c0df 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 diff --git a/.github/workflows/releaser-pleaser.yaml b/.github/workflows/releaser-pleaser.yaml index 5536579..e40d6cd 100644 --- a/.github/workflows/releaser-pleaser.yaml +++ b/.github/workflows/releaser-pleaser.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: ref: main From e83a7c9a23270f264b954fd4c70f6387aff93b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 24 Aug 2025 16:52:01 +0200 Subject: [PATCH 15/20] ci: mise cleanup (#242) - Renovate does not find the "github:*" dependencies in `mise.toml` - The `mdbooks` tools was still installed manually with our own action, this is removed and mise is used instead. --- .github/actions/setup-mdbook/action.yaml | 16 ---------------- .github/workflows/docs.yaml | 4 ---- mise.toml | 4 ++-- 3 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 .github/actions/setup-mdbook/action.yaml diff --git a/.github/actions/setup-mdbook/action.yaml b/.github/actions/setup-mdbook/action.yaml deleted file mode 100644 index 23e0665..0000000 --- a/.github/actions/setup-mdbook/action.yaml +++ /dev/null @@ -1,16 +0,0 @@ -name: "Setup mdbook" -inputs: - version: - description: "mdbook version" - -runs: - using: composite - steps: - - name: Setup mdbook - shell: bash - env: - url: https://github.com/rust-lang/mdbook/releases/download/${{ inputs.version }}/mdbook-${{ inputs.version }}-x86_64-unknown-linux-gnu.tar.gz - run: | - mkdir mdbook - curl -sSL "$url" | tar -xz --directory=./mdbook - echo `pwd`/mdbook >> $GITHUB_PATH diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 4e728bc..b7c6dfe 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -17,10 +17,6 @@ jobs: with: lfs: "true" - - uses: ./.github/actions/setup-mdbook - with: - version: v0.4.52 # renovate: datasource=github-releases depName=rust-lang/mdbook - - name: Build Book working-directory: docs run: mdbook build diff --git a/mise.toml b/mise.toml index d520f6f..d72aea4 100644 --- a/mise.toml +++ b/mise.toml @@ -2,8 +2,8 @@ go = "1.25.0" golangci-lint = "v2.4.0" goreleaser = "v2.9.0" -"github:rust-lang/mdBook" = "v0.4.52" -"github:ko-build/ko" = "v0.18.0" +"github:rust-lang/mdBook" = "v0.4.52" # renovate: datasource=github-releases depName=rust-lang/mdbook +"github:ko-build/ko" = "v0.18.0" # renovate: datasource=github-releases depName=ko-build/ko [settings] # Experimental features are needed for the Go backend From 44b76e55f82e2e534fa45facf02c114cc41a5305 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 24 Aug 2025 16:56:11 +0200 Subject: [PATCH 16/20] ci: allow regex manager in toml files for mise (#243) --- .github/renovate.json5 | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 83d07be..751d049 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -64,6 +64,7 @@ customType: 'regex', managerFilePatterns: [ '/.+\\.ya?ml$/', + '/.+\\.toml$/' ], matchStrings: [ ': (?.+) # renovate: datasource=(?[a-z-]+) depName=(?[^\\s]+)(?: lookupName=(?[^\\s]+))?(?: versioning=(?[a-z-]+))?(?: extractVersion=(?[^\\s]+))?', From f077b647e70e3107ebe013c0a1db551434b88bc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 24 Aug 2025 17:07:58 +0200 Subject: [PATCH 17/20] ci: separate renovate manager for toml (#244) --- .github/renovate.json5 | 10 +++++++++- mise.toml | 4 ++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 751d049..5110561 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -64,12 +64,20 @@ customType: 'regex', managerFilePatterns: [ '/.+\\.ya?ml$/', - '/.+\\.toml$/' ], matchStrings: [ ': (?.+) # renovate: datasource=(?[a-z-]+) depName=(?[^\\s]+)(?: lookupName=(?[^\\s]+))?(?: versioning=(?[a-z-]+))?(?: extractVersion=(?[^\\s]+))?', ], }, + { + customType: 'regex', + managerFilePatterns: [ + '/.+\\.toml$/' + ], + matchStrings: [ + '= "(?.+)" # renovate: datasource=(?[a-z-]+) depName=(?[^\\s]+)(?: lookupName=(?[^\\s]+))?(?: versioning=(?[a-z-]+))?(?: extractVersion=(?[^\\s]+))?', + ], + } ], postUpdateOptions: [ 'gomodUpdateImportPaths', diff --git a/mise.toml b/mise.toml index d72aea4..bc2630d 100644 --- a/mise.toml +++ b/mise.toml @@ -2,8 +2,8 @@ go = "1.25.0" golangci-lint = "v2.4.0" goreleaser = "v2.9.0" -"github:rust-lang/mdBook" = "v0.4.52" # renovate: datasource=github-releases depName=rust-lang/mdbook -"github:ko-build/ko" = "v0.18.0" # renovate: datasource=github-releases depName=ko-build/ko +mdbook = "v0.4.52" # renovate: datasource=github-releases depName=rust-lang/mdbook +ko = "v0.18.0" # renovate: datasource=github-releases depName=ko-build/ko [settings] # Experimental features are needed for the Go backend From 7f6997111644206f8655227a4867549eed5aa69a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 15 Jun 2025 16:25:34 +0200 Subject: [PATCH 18/20] feat(forge): add new forge for forgejo We only support repositories hosted on Forgejo instances, but not Forgejo Actions or Woodpecker as CI solutions for now. --- cmd/rp/cmd/run.go | 24 ++ go.mod | 7 + go.sum | 19 ++ internal/forge/forgejo/forgejo.go | 529 ++++++++++++++++++++++++++++++ 4 files changed, 579 insertions(+) create mode 100644 internal/forge/forgejo/forgejo.go diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index d6bbe4b..3d575b0 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -11,6 +11,7 @@ import ( rp "github.com/apricote/releaser-pleaser" "github.com/apricote/releaser-pleaser/internal/commitparser/conventionalcommits" "github.com/apricote/releaser-pleaser/internal/forge" + "github.com/apricote/releaser-pleaser/internal/forge/forgejo" "github.com/apricote/releaser-pleaser/internal/forge/github" "github.com/apricote/releaser-pleaser/internal/forge/gitlab" "github.com/apricote/releaser-pleaser/internal/log" @@ -26,6 +27,10 @@ func newRunCommand() *cobra.Command { flagRepo string flagExtraFiles string flagUpdaters []string + + flagAPIURL string + flagAPIToken string + flagUsername string ) var cmd = &cobra.Command{ @@ -68,6 +73,21 @@ func newRunCommand() *cobra.Command { Owner: flagOwner, Repo: flagRepo, }) + case "forgejo": + logger.DebugContext(ctx, "using forge Forgejo") + f, err = forgejo.New(logger, &forgejo.Options{ + Options: forgeOptions, + Owner: flagOwner, + Repo: flagRepo, + + APIURL: flagAPIURL, + APIToken: flagAPIToken, + Username: flagUsername, + }) + if err != nil { + logger.ErrorContext(ctx, "failed to create client", "err", err) + return fmt.Errorf("failed to create forgejo client: %w", err) + } default: return fmt.Errorf("unknown --forge: %s", flagForge) } @@ -110,6 +130,10 @@ func newRunCommand() *cobra.Command { cmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") cmd.PersistentFlags().StringSliceVar(&flagUpdaters, "updaters", []string{}, "") + cmd.PersistentFlags().StringVar(&flagAPIURL, "api-url", "", "") + cmd.PersistentFlags().StringVar(&flagAPIToken, "api-token", "", "") + cmd.PersistentFlags().StringVar(&flagUsername, "username", "", "") + return cmd } diff --git a/go.mod b/go.mod index 84390a0..d589450 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.23.2 toolchain go1.25.0 require ( + codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.0.0-00010101000000-000000000000 github.com/blang/semver/v4 v4.0.0 github.com/go-git/go-billy/v5 v5.6.2 github.com/go-git/go-git/v5 v5.16.2 @@ -20,17 +21,21 @@ require ( require ( dario.cat/mergo v1.0.1 // indirect + github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -49,3 +54,5 @@ require ( gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 => codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561 diff --git a/go.sum b/go.sum index 283db4c..f0a4935 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,9 @@ +codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561 h1:ZFGmrGQ7cd2mbSLrfjrj3COwPKFfKM6sDO/IsrGDW7w= +codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561/go.mod h1:2i9GsyawlJtVMO5pTS/Om5uo2O3JN/eCjGWy5v15NGg= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= @@ -19,6 +23,8 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL 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/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= @@ -27,6 +33,8 @@ github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= @@ -50,6 +58,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -111,16 +121,23 @@ gitlab.com/gitlab-org/api/client-go v0.142.0 h1:cR8+RhDc7ooH0SiGNhgm3Nf5ZpW5D1R3 gitlab.com/gitlab-org/api/client-go v0.142.0/go.mod h1:3YuWlZCirs2TTcaAzM6qNwVHB7WvV67ATb0GGpBCdlQ= go.abhg.dev/goldmark/toc v0.11.0 h1:IRixVy3/yVPKvFBc37EeBPi8XLTXrtH6BYaonSjkF8o= go.abhg.dev/goldmark/toc v0.11.0/go.mod h1:XMFIoI1Sm6dwF9vKzVDOYE/g1o5BmKXghLG8q/wJNww= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -132,6 +149,8 @@ golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= diff --git a/internal/forge/forgejo/forgejo.go b/internal/forge/forgejo/forgejo.go new file mode 100644 index 0000000..9b0c548 --- /dev/null +++ b/internal/forge/forgejo/forgejo.go @@ -0,0 +1,529 @@ +package forgejo + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strings" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + "github.com/blang/semver/v4" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + + "github.com/apricote/releaser-pleaser/internal/forge" + "github.com/apricote/releaser-pleaser/internal/git" + "github.com/apricote/releaser-pleaser/internal/pointer" + "github.com/apricote/releaser-pleaser/internal/releasepr" +) + +const () + +var _ forge.Forge = &Forgejo{} + +type Forgejo struct { + options *Options + + client *forgejo.Client + log *slog.Logger +} + +func (f *Forgejo) RepoURL() string { + return fmt.Sprintf("%s/%s/%s", f.options.APIURL, f.options.Owner, f.options.Repo) +} + +func (f *Forgejo) CloneURL() string { + return fmt.Sprintf("%s.git", f.RepoURL()) +} + +func (f *Forgejo) ReleaseURL(version string) string { + return fmt.Sprintf("%s/releases/tag/%s", f.RepoURL(), version) +} + +func (f *Forgejo) PullRequestURL(id int) string { + return fmt.Sprintf("%s/pulls/%d", f.RepoURL(), id) +} + +func (f *Forgejo) GitAuth() transport.AuthMethod { + return &http.BasicAuth{ + Username: f.options.Username, + Password: f.options.APIToken, + } +} + +func (f *Forgejo) CommitAuthor(ctx context.Context) (git.Author, error) { + f.log.DebugContext(ctx, "getting commit author from current token user") + + user, _, err := f.client.GetMyUserInfo() + if err != nil { + return git.Author{}, err + } + + // TODO: Same for other forges? + name := user.FullName + if name == "" { + name = user.UserName + } + + return git.Author{ + Name: name, + Email: user.Email, + }, nil +} + +func (f *Forgejo) LatestTags(ctx context.Context) (git.Releases, error) { + f.log.DebugContext(ctx, "listing all tags in forgejo repository") + + tags, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Tag, *forgejo.Response, error) { + return f.client.ListRepoTags(f.options.Owner, f.options.Repo, + forgejo.ListRepoTagsOptions{ListOptions: listOptions}, + ) + }) + if err != nil { + return git.Releases{}, err + } + + var releases git.Releases + + for _, fTag := range tags { + tag := &git.Tag{ + Hash: fTag.Commit.SHA, + Name: fTag.Name, + } + + version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v")) + if err != nil { + f.log.WarnContext( + ctx, "unable to parse tag as semver, skipping", + "tag.name", tag.Name, + "tag.hash", tag.Hash, + "error", err, + ) + continue + } + + if releases.Latest == nil { + releases.Latest = tag + } + if len(version.Pre) == 0 { + // Stable version tag + // We return once we have found the latest stable tag, not needed to look at every single tag. + releases.Stable = tag + break + } + } + + return releases, nil +} + +func (f *Forgejo) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) { + var repositoryCommits []*forgejo.Commit + var err error + if tag != nil { + repositoryCommits, err = f.commitsSinceTag(ctx, tag) + } else { + repositoryCommits, err = f.commitsSinceInit(ctx) + } + + if err != nil { + return nil, err + } + + var commits = make([]git.Commit, 0, len(repositoryCommits)) + for _, fCommit := range repositoryCommits { + commit := git.Commit{ + Hash: fCommit.SHA, + Message: fCommit.RepoCommit.Message, + } + commit.PullRequest, err = f.prForCommit(ctx, commit) + if err != nil { + return nil, fmt.Errorf("failed to check for commit pull request: %w", err) + } + + commits = append(commits, commit) + } + + return commits, nil +} + +func (f *Forgejo) commitsSinceTag(_ context.Context, tag *git.Tag) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("base", tag.Hash, "head", head) + log.Debug("comparing commits") + + compare, _, err := f.client.CompareCommits( + f.options.Owner, f.options.Repo, + tag.Hash, head) + if err != nil { + return nil, err + } + + return compare.Commits, nil +} + +func (f *Forgejo) commitsSinceInit(_ context.Context) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("head", head) + log.Debug("listing all commits") + + repositoryCommits, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Commit, *forgejo.Response, error) { + return f.client.ListRepoCommits( + f.options.Owner, f.options.Repo, + forgejo.ListCommitOptions{ + ListOptions: listOptions, + SHA: f.options.BaseBranch, + }) + }) + if err != nil { + return nil, err + } + + return repositoryCommits, nil +} + +func (f *Forgejo) prForCommit(_ context.Context, commit git.Commit) (*git.PullRequest, error) { + // We naively look up the associated PR for each commit through the "List pull requests associated with a commit" + // endpoint. This requires len(commits) requests. + // Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request, + // but worst case we need to look up all PRs made in the repository ever. + + f.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash) + + pullRequest, _, err := f.client.GetCommitPullRequest( + f.options.Owner, f.options.Repo, + commit.Hash, + ) + if err != nil { + if strings.HasPrefix(err.Error(), "pull request does not exist") { + return nil, nil + } + + return nil, err + } + + return forgejoPRToPullRequest(pullRequest), nil +} + +func (f *Forgejo) EnsureLabelsExist(_ context.Context, labels []releasepr.Label) error { + f.log.Debug("fetching labels on repo") + fLabels, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels( + f.options.Owner, f.options.Repo, + forgejo.ListLabelsOptions{ListOptions: listOptions}) + }) + if err != nil { + return err + } + + for _, label := range labels { + if !slices.ContainsFunc(fLabels, func(fLabel *forgejo.Label) bool { + return fLabel.Name == label.Name + }) { + f.log.Info("creating label in repository", "label.name", label.Name) + _, _, err = f.client.CreateLabel( + f.options.Owner, f.options.Repo, + forgejo.CreateLabelOption{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + }, + ) + if err != nil { + return err + } + } + } + + return nil +} + +func (f *Forgejo) PullRequestForBranch(_ context.Context, branch string) (*releasepr.ReleasePullRequest, error) { + prs, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + ListOptions: listOptions, + State: forgejo.StateOpen, + }, + ) + }, + ) + if err != nil { + return nil, err + } + + for _, pr := range prs { + if pr.Base.Ref == f.options.BaseBranch && pr.Head.Ref == branch { + return forgejoPRToReleasePullRequest(pr), nil + } + } + + return nil, nil +} + +func (f *Forgejo) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { + fPR, _, err := f.client.CreatePullRequest( + f.options.Owner, f.options.Repo, + forgejo.CreatePullRequestOption{ + Title: pr.Title, + Head: pr.Head, + Base: f.options.BaseBranch, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + // TODO: String ID? + pr.ID = int(fPR.ID) + + err = f.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) UpdatePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + Title: pr.Title, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) SetPullRequestLabels(_ context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error { + allLabels, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels(f.options.Owner, f.options.Repo, forgejo.ListLabelsOptions{ListOptions: listOptions}) + }, + ) + if err != nil { + return err + } + + findLabel := func(labelName string) *forgejo.Label { + for _, fLabel := range allLabels { + if fLabel.Name == labelName { + return fLabel + } + } + + return nil + } + + for _, label := range remove { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to remove label %q, not found in API", label.Name) + } + + _, err = f.client.DeleteIssueLabel( + f.options.Owner, f.options.Repo, + int64(pr.ID), fLabel.ID, + ) + if err != nil { + return err + } + } + + addIDs := make([]int64, 0, len(add)) + for _, label := range add { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to add label %q, not found in API", label.Name) + } + + addIDs = append(addIDs, fLabel.ID) + } + + _, _, err = f.client.AddIssueLabels( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.IssueLabelsOption{Labels: addIDs}, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) ClosePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + State: pointer.Pointer(forgejo.StateClosed), + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) PendingReleases(_ context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) { + fPRs, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + // Filtering by Label ID is possible in the API, but not implemented in the Go SDK. + State: forgejo.StateClosed, + ListOptions: listOptions, + }) + }) + if err != nil { + // "The target couldn't be found." means that the repo does not have pull requests activated. + return nil, err + } + + prs := make([]*releasepr.ReleasePullRequest, 0, len(fPRs)) + + for _, pr := range fPRs { + pending := slices.ContainsFunc(pr.Labels, func(l *forgejo.Label) bool { + return l.Name == pendingLabel.Name + }) + if !pending { + continue + } + + // pr.Merged is always nil :( + if !pr.HasMerged { + // Closed and not merged + continue + } + + prs = append(prs, forgejoPRToReleasePullRequest(pr)) + } + + return prs, nil +} + +func (f *Forgejo) CreateRelease(_ context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error { + // latest can not be set through the API + + _, _, err := f.client.CreateRelease( + f.options.Owner, f.options.Repo, + forgejo.CreateReleaseOption{ + TagName: title, + Target: commit.Hash, + Title: title, + Note: changelog, + IsPrerelease: preRelease, + }, + ) + if err != nil { + return err + } + + return nil +} + +func all[T any](f func(listOptions forgejo.ListOptions) ([]T, *forgejo.Response, error)) ([]T, error) { + results := make([]T, 0) + page := 1 + + for { + pageResults, resp, err := f(forgejo.ListOptions{Page: page}) + if err != nil { + return nil, err + } + + results = append(results, pageResults...) + + if page == resp.LastPage || resp.LastPage == 0 { + return results, nil + } + page = resp.NextPage + } +} + +func forgejoPRToPullRequest(pr *forgejo.PullRequest) *git.PullRequest { + return &git.PullRequest{ + ID: int(pr.ID), + Title: pr.Title, + Description: pr.Body, + } +} + +func forgejoPRToReleasePullRequest(pr *forgejo.PullRequest) *releasepr.ReleasePullRequest { + labels := make([]releasepr.Label, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labelName := label.Name + if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool { + return label.Name == labelName + }); i >= 0 { + labels = append(labels, releasepr.KnownLabels[i]) + } + } + + var releaseCommit *git.Commit + if pr.MergedCommitID != nil { + releaseCommit = &git.Commit{Hash: *pr.MergedCommitID} + } + + return &releasepr.ReleasePullRequest{ + PullRequest: *forgejoPRToPullRequest(pr), + Labels: labels, + + Head: pr.Head.Ref, + ReleaseCommit: releaseCommit, + } +} + +func (g *Options) autodiscover() { + // TODO +} + +func (g *Options) ClientOptions() []forgejo.ClientOption { + options := []forgejo.ClientOption{} + + if g.APIToken != "" { + options = append(options, forgejo.SetToken(g.APIToken)) + } + + return options +} + +type Options struct { + forge.Options + + Owner string + Repo string + + APIURL string + Username string + APIToken string +} + +func New(log *slog.Logger, options *Options) (*Forgejo, error) { + options.autodiscover() + + client, err := forgejo.NewClient(options.APIURL, options.ClientOptions()...) + if err != nil { + return nil, err + } + + client.SetUserAgent("releaser-pleaser") + + f := &Forgejo{ + options: options, + + client: client, + log: log.With("forge", "forgejo"), + } + + return f, nil +} From dda12c62c772b2871990700c74fd43fb33619ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Thu, 19 Jun 2025 15:41:49 +0200 Subject: [PATCH 19/20] test(e2e): introduce e2e test framework with local forgejo --- .github/workflows/ci.yaml | 29 ++++++++ codecov.yaml | 2 + go.mod | 2 +- test/e2e/forge.go | 19 ++++++ test/e2e/forgejo/app.ini | 23 +++++++ test/e2e/forgejo/compose.yaml | 16 +++++ test/e2e/forgejo/forge.go | 113 +++++++++++++++++++++++++++++++ test/e2e/forgejo/forgejo_test.go | 39 +++++++++++ test/e2e/framework.go | 96 ++++++++++++++++++++++++++ 9 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 codecov.yaml create mode 100644 test/e2e/forge.go create mode 100644 test/e2e/forgejo/app.ini create mode 100644 test/e2e/forgejo/compose.yaml create mode 100644 test/e2e/forgejo/forge.go create mode 100644 test/e2e/forgejo/forgejo_test.go create mode 100644 test/e2e/framework.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cb821e6..65aed30 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,35 @@ jobs: uses: codecov/codecov-action@fdcc8476540edceab3de004e990f80d881c6cc00 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} + flags: unit + + test-e2e-forgejo: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: go.mod + + # We can not use "jobs..services". + # We want to mount the config file, which is only available after "Checkout". + - name: Start Forgejo + working-directory: test/e2e/forgejo + run: docker compose up --wait + + - name: Run tests + run: go test -tags e2e_forgejo -v -race -coverpkg=./... -coverprofile=coverage.txt ./test/e2e/forgejo + + + - name: Upload results to Codecov + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: e2e go-mod-tidy: runs-on: ubuntu-latest diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..b52d0c4 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,2 @@ +ignore: + - "test" diff --git a/go.mod b/go.mod index d589450..3034969 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/apricote/releaser-pleaser -go 1.23.2 +go 1.24 toolchain go1.25.0 diff --git a/test/e2e/forge.go b/test/e2e/forge.go new file mode 100644 index 0000000..ea55545 --- /dev/null +++ b/test/e2e/forge.go @@ -0,0 +1,19 @@ +package e2e + +import ( + "context" + "testing" +) + +type TestForge interface { + Init(ctx context.Context, runID string) error + CreateRepo(t *testing.T, opts CreateRepoOpts) (*Repository, error) + + RunArguments() []string +} + +type CreateRepoOpts struct { + Name string + Description string + DefaultBranch string +} diff --git a/test/e2e/forgejo/app.ini b/test/e2e/forgejo/app.ini new file mode 100644 index 0000000..4a22d2e --- /dev/null +++ b/test/e2e/forgejo/app.ini @@ -0,0 +1,23 @@ +WORK_PATH = /data/gitea + +[database] +DB_TYPE = sqlite3 +PATH = /data/gitea/forgejo.db + +[security] +INSTALL_LOCK = true +SECRET_KEY = releaser-pleaser +INTERNAL_TOKEN = releaser-pleaser + +[service] +REGISTER_EMAIL_CONFIRM = false +ENABLE_NOTIFY_MAIL = false +DISABLE_REGISTRATION = true + +[server] +DOMAIN = localhost +HTTP_PORT = 3000 +ROOT_URL = http://localhost:3000/ + +[oauth2] +JWT_SECRET = rTD-FL2n_aBB6v4AOcr5lBvwgZ6PSr3HGZAuNH6nMu8 diff --git a/test/e2e/forgejo/compose.yaml b/test/e2e/forgejo/compose.yaml new file mode 100644 index 0000000..3f20c3a --- /dev/null +++ b/test/e2e/forgejo/compose.yaml @@ -0,0 +1,16 @@ +services: + forgejo: + image: codeberg.org/forgejo/forgejo:11 + ports: + - '3000:3000' + - '222:22' + volumes: + - data:/data/gitea + - ./app.ini:/data/gitea/conf/app.ini:ro + + healthcheck: + test: ["CMD", "curl", "localhost:3000/api/healthz"] + + +volumes: + data: diff --git a/test/e2e/forgejo/forge.go b/test/e2e/forgejo/forge.go new file mode 100644 index 0000000..c631cb8 --- /dev/null +++ b/test/e2e/forgejo/forge.go @@ -0,0 +1,113 @@ +package forgejo + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "strings" + "testing" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/apricote/releaser-pleaser/test/e2e" +) + +const ( + TestAPIURL = "http://localhost:3000" + + TestUserNameTemplate = "rp-%s" + TestUserPassword = "releaser-pleaser" + TestUserEmailTemplate = "releaser-pleaser-%s@example.com" + TestTokenName = "rp" + TestTokenScopes = "write:user,write:issue,write:repository" +) + +type TestForge struct { + username string + token string + client *forgejo.Client +} + +func (f *TestForge) Init(ctx context.Context, runID string) error { + if err := f.initUser(ctx, runID); err != nil { + return err + } + if err := f.initClient(ctx); err != nil { + return err + } + + return nil +} + +func (f *TestForge) initUser(ctx context.Context, runID string) error { + f.username = fmt.Sprintf(TestUserNameTemplate, runID) + + //gosec:disable G204 + if output, err := exec.CommandContext(ctx, + "docker", "compose", "exec", "--user=1000", "forgejo", + "forgejo", "admin", "user", "create", + "--username", f.username, + "--password", TestUserPassword, + "--email", fmt.Sprintf(TestUserEmailTemplate, runID), + "--must-change-password=false", + ).CombinedOutput(); err != nil { + slog.Debug("create forgejo user output", "output", output) + return fmt.Errorf("failed to create forgejo user: %w", err) + } + + //gosec:disable G204 + token, err := exec.CommandContext(ctx, + "docker", "compose", "exec", "--user=1000", "forgejo", + "forgejo", "admin", "user", "generate-access-token", + "--username", f.username, + "--token-name", TestTokenName, + "--scopes", TestTokenScopes, + "--raw", + ).Output() + if err != nil { + return fmt.Errorf("failed to create forgejo token: %w", err) + } + + f.token = strings.TrimSpace(string(token)) + + return nil +} + +func (f *TestForge) initClient(ctx context.Context) (err error) { + f.client, err = forgejo.NewClient(TestAPIURL, + forgejo.SetToken(f.token), + forgejo.SetUserAgent("releaser-pleaser-e2e-tests"), + forgejo.SetContext(ctx), + // forgejo.SetDebugMode(), + ) + return err +} + +func (f *TestForge) CreateRepo(t *testing.T, opts e2e.CreateRepoOpts) (*e2e.Repository, error) { + t.Helper() + + repo, _, err := f.client.CreateRepo(forgejo.CreateRepoOption{ + Name: opts.Name, + Description: opts.Description, + DefaultBranch: opts.DefaultBranch, + Readme: "Default", + AutoInit: true, + }) + if err != nil { + return nil, err + } + + return &e2e.Repository{ + Name: repo.Name, + }, nil +} + +func (f *TestForge) RunArguments() []string { + return []string{"--forge=forgejo", + fmt.Sprintf("--owner=%s", f.username), + fmt.Sprintf("--api-url=%s", TestAPIURL), + fmt.Sprintf("--api-token=%s", f.token), + fmt.Sprintf("--username=%s", f.username), + } +} diff --git a/test/e2e/forgejo/forgejo_test.go b/test/e2e/forgejo/forgejo_test.go new file mode 100644 index 0000000..b52504f --- /dev/null +++ b/test/e2e/forgejo/forgejo_test.go @@ -0,0 +1,39 @@ +//go:build e2e_forgejo + +package forgejo + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/apricote/releaser-pleaser/test/e2e" +) + +var ( + f *e2e.Framework +) + +func TestMain(m *testing.M) { + ctx := context.Background() + + var err error + f, err = e2e.NewFramework(ctx, &TestForge{}) + if err != nil { + slog.Error("failed to set up test framework", "err", err) + } + + os.Exit(m.Run()) +} + +func TestCreateRepository(t *testing.T) { + _ = f.NewRepository(t, t.Name()) +} + +func TestRun(t *testing.T) { + repo := f.NewRepository(t, t.Name()) + require.NoError(t, f.Run(t, repo, []string{})) +} diff --git a/test/e2e/framework.go b/test/e2e/framework.go new file mode 100644 index 0000000..7352f14 --- /dev/null +++ b/test/e2e/framework.go @@ -0,0 +1,96 @@ +package e2e + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/apricote/releaser-pleaser/cmd/rp/cmd" +) + +const ( + TestDefaultBranch = "main" +) + +func randomString() string { + randomBytes := make([]byte, 4) + if _, err := rand.Read(randomBytes); err != nil { + panic(err) + } + return hex.EncodeToString(randomBytes) +} + +type Framework struct { + runID string + forge TestForge +} + +func NewFramework(ctx context.Context, forge TestForge) (*Framework, error) { + f := &Framework{ + runID: randomString(), + forge: forge, + } + + err := forge.Init(ctx, f.runID) + if err != nil { + return nil, err + } + + return f, nil +} + +type Repository struct { + Name string +} + +func (f *Framework) NewRepository(t *testing.T, name string) *Repository { + t.Helper() + + r := &Repository{ + Name: fmt.Sprintf("%s-%s-%s", name, f.runID, randomString()), + } + + repo, err := f.forge.CreateRepo(t, CreateRepoOpts{ + Name: r.Name, + Description: name, + DefaultBranch: TestDefaultBranch, + }) + require.NoError(t, err) + require.NotNil(t, repo) + + return r +} + +func (f *Framework) Run(t *testing.T, r *Repository, extraFiles []string) error { + t.Helper() + + ctx := t.Context() + + rootCmd := cmd.NewRootCmd() + rootCmd.SetArgs(append([]string{ + "run", + fmt.Sprintf("--repo=%s", r.Name), + fmt.Sprintf("--extra-files=%q", strings.Join(extraFiles, "\n")), + }, f.forge.RunArguments()...)) + + var stdout, stderr bytes.Buffer + + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&stderr) + + err := rootCmd.ExecuteContext(ctx) + + stdoutString := stdout.String() + stderrString := stderr.String() + + t.Log(stdoutString) + t.Log(stderrString) + + return err +} From 1750e222f778c962b0a75b057c01af84ed6433de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sun, 24 Aug 2025 15:53:26 +0200 Subject: [PATCH 20/20] chore: add common tasks to mise --- mise.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/mise.toml b/mise.toml index bc2630d..4618b9c 100644 --- a/mise.toml +++ b/mise.toml @@ -8,3 +8,18 @@ ko = "v0.18.0" # renovate: datasource=github-releases depName=ko-build/ko [settings] # Experimental features are needed for the Go backend experimental = true + +[tasks.lint] +run = "golangci-lint run" + +[tasks.test] +run = "go test -v -race ./..." + +[tasks.test-e2e] +run = "go test -tags e2e_forgejo -v -race ./test/e2e/forgejo" + +[tasks.e2e-forgejo-start] +run = "docker compose --project-directory ./test/e2e/forgejo up -d --wait" + +[tasks.e2e-forgejo-stop] +run = "docker compose --project-directory ./test/e2e/forgejo down"