Compare commits

...

269 commits

Author SHA1 Message Date
renovate[bot]
944b70cee9
deps: update dependency golangci-lint to v2.8.0 (#325)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-08 01:43:29 +00:00
renovate[bot]
2a1a08057b
deps: update module github.com/yuin/goldmark to v1.7.16 (#324)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-06 10:02:37 +00:00
renovate[bot]
60cbffba9d
deps: update module github.com/yuin/goldmark to v1.7.15 (#323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-05 21:42:52 +00:00
renovate[bot]
6ef1405140
deps: update module github.com/yuin/goldmark to v1.7.14 (#322)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-01-04 14:32:11 +00:00
renovate[bot]
d9c8d3e5af
deps: update dependency ko-build/ko to v0.18.1 (#320)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-13 16:47:59 +00:00
renovate[bot]
b48c9a654f
deps: update dependency rust-lang/mdbook to v0.5.2 (#319)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-11 22:50:29 +00:00
renovate[bot]
163eaf31a6
deps: update dependency golangci-lint to v2.7.2 (#316)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-07 18:13:31 +00:00
renovate[bot]
804cf8040a
deps: update dependency golangci-lint to v2.7.1 (#313)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-06 18:00:02 +00:00
renovate[bot]
60d9aa3982
deps: update module github.com/go-git/go-billy/v5 to v5.7.0 (#315)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-06 13:00:46 +00:00
renovate[bot]
8d6175c13b
deps: update module github.com/spf13/cobra to v1.10.2 (#312)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-04 03:50:01 +00:00
renovate[bot]
6dfa96a9ba
deps: update dependency golangci-lint to v2.7.0 (#311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-03 21:01:35 +00:00
renovate[bot]
fb2a0b8167
deps: update dependency go to v1.25.5 (#310)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-12-02 17:37:09 +00:00
renovate[bot]
db611e5cc2
deps: update module gitlab.com/gitlab-org/api/client-go to v0.161.1 (#309)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 21:30:33 +00:00
renovate[bot]
b451c08634
deps: update module gitlab.com/gitlab-org/api/client-go to v0.160.2 (#308)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 12:50:46 +00:00
renovate[bot]
9592a6a975
deps: update module github.com/go-git/go-git/v5 to v5.16.4 (#307)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-24 02:08:18 +00:00
renovate[bot]
e5bccc9fb9
deps: update module golang.org/x/crypto to v0.45.0 [security] (#306)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 09:57:10 +00:00
renovate[bot]
995f4beb9a
deps: update dependency rust-lang/mdbook to v0.5.1 (#305)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-20 05:39:33 +00:00
renovate[bot]
ec851d7511
deps: update module gitlab.com/gitlab-org/api/client-go to v0.160.1 (#304)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-19 21:04:09 +00:00
renovate[bot]
ad845e61c7
deps: update dependency rust-lang/mdbook to v0.5.0 (#303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-18 06:50:50 +00:00
renovate[bot]
0f040ff8e7
deps: update dependency golangci-lint to v2.6.2 (#301)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-14 18:13:08 +00:00
renovate[bot]
6dd0424029
deps: update module gitlab.com/gitlab-org/api/client-go to v0.160.0 (#300)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-13 00:00:03 +00:00
renovate[bot]
291581ef6d
deps: update dependency go to v1.25.4 (#299)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 21:07:16 +00:00
renovate[bot]
29a033103d
deps: update module gitlab.com/gitlab-org/api/client-go to v0.159.0 (#298)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-05 01:05:09 +00:00
renovate[bot]
c1c2111e03
deps: update dependency golangci-lint to v2.6.1 (#297)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-04 12:51:40 +00:00
renovate[bot]
c4796a546e
deps: update module gitlab.com/gitlab-org/api/client-go to v0.158.0 (#296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-11-03 11:31:58 +00:00
renovate[bot]
cb92e2b67f
deps: update dependency golangci-lint to v2.6.0 (#295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-30 01:06:35 +00:00
renovate[bot]
23e9d06c6e
deps: update module gitlab.com/gitlab-org/api/client-go to v0.157.1 (#294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-28 16:15:14 +00:00
renovate[bot]
0129c78abc
deps: update dependency go to v1.25.3 (#293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 22:16:30 +00:00
renovate[bot]
226548adfa
deps: update module gitlab.com/gitlab-org/api/client-go to v0.157.0 (#292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-13 18:11:57 +00:00
renovate[bot]
0d2efe0a6d
deps: update module gitlab.com/gitlab-org/api/client-go to v0.156.0 (#291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 08:51:53 +00:00
renovate[bot]
cead3fb5c1
deps: update module gitlab.com/gitlab-org/api/client-go to v0.155.0 (#290)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-10 02:00:41 +00:00
renovate[bot]
918249a0d3
deps: update module gitlab.com/gitlab-org/api/client-go to v0.154.0 (#289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 15:03:19 +00:00
renovate[bot]
f24b69e8fa
deps: update module gitlab.com/gitlab-org/api/client-go to v0.153.0 (#288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 09:01:44 +00:00
renovate[bot]
d95b779f83
deps: update dependency go to v1.25.2 (#287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-08 04:15:40 +00:00
renovate[bot]
e32838e3d0
deps: update module gitlab.com/gitlab-org/api/client-go to v0.152.0 (#286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-07 00:46:25 +00:00
renovate[bot]
8607cb6f71
deps: update module github.com/go-git/go-git/v5 to v5.16.3 (#284)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-05 21:47:34 +00:00
renovate[bot]
ff899fe9e8
deps: update module gitlab.com/gitlab-org/api/client-go to v0.151.0 (#283)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-05 00:49:57 +00:00
renovate[bot]
0d16c770d3
deps: update module gitlab.com/gitlab-org/api/client-go to v0.150.0 (#281)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 10:02:04 +00:00
renovate[bot]
21e3fdbcad
deps: update module gitlab.com/gitlab-org/api/client-go to v0.149.0 (#280)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-03 01:14:18 +00:00
renovate[bot]
71364599d8
deps: update module gitlab.com/gitlab-org/api/client-go to v0.148.1 (#279)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-26 17:51:38 +00:00
fa27415be5
chore(main): release v0.7.1 (#278) 2025-09-26 05:36:27 +00:00
Jonas L.
b0c50518b3
fix: no html escaping for changelog template (#277) 2025-09-25 12:40:35 +02:00
Jonas L.
612928a382
fix: using code blocks within release-notes (#275)
Increase the number of code blocks backticks to 4 for the release note prefix and suffix, to allow users to embed their own code blocks using only 3 backticks.
2025-09-25 12:25:35 +02:00
renovate[bot]
93bb42e781
deps: update module gitlab.com/gitlab-org/api/client-go to v0.148.0 (#274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-23 11:10:35 +00:00
renovate[bot]
63a8b91b34
deps: update module gitlab.com/gitlab-org/api/client-go to v0.147.1 (#273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 16:42:57 +00:00
renovate[bot]
d0a44e3fb8
deps: update module gitlab.com/gitlab-org/api/client-go to v0.147.0 (#272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-22 10:09:57 +00:00
renovate[bot]
63acf9aa3a
deps: update dependency golangci-lint to v2.5.0 (#271)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-21 20:45:11 +00:00
renovate[bot]
250cc6c2aa
deps: update module gitlab.com/gitlab-org/api/client-go to v0.146.0 (#270)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-18 14:32:34 +00:00
renovate[bot]
ef94c3c8f7
deps: update module gitlab.com/gitlab-org/api/client-go to v0.145.0 (#269)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-16 10:51:32 +00:00
renovate[bot]
f300bbb6b0
deps: update actions/checkout digest to 08eba0b (#264) 2025-09-15 14:47:12 +02:00
renovate[bot]
cc627599db
deps: update codecov/codecov-action digest to 5a10915 (#266)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-15 14:46:37 +02:00
renovate[bot]
8c6b99560c
deps: update module codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 to v2.2.0 (#268)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-14 09:39:48 +00:00
renovate[bot]
2c1d29f639
deps: update module gitlab.com/gitlab-org/api/client-go to v0.144.1 (#267)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-14 01:31:08 +00:00
fcf7906149
test: add e2e tests with local Forgejo instance (#201)
* 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.

* test(e2e): introduce e2e test framework with local forgejo
2025-09-13 12:00:54 +02:00
renovate[bot]
afef176e37
deps: update module gitlab.com/gitlab-org/api/client-go to v0.144.0 (#263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-12 15:20:21 +00:00
renovate[bot]
e3d2cfa6b8
deps: update codecov/codecov-action digest to 5a10915 (#253) 2025-09-11 13:30:36 +02:00
renovate[bot]
6b9738bcea
deps: update module gitlab.com/gitlab-org/api/client-go to v0.143.3 (#260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-10 06:39:09 +00:00
renovate[bot]
e7950cfbc1
deps: update module gitlab.com/gitlab-org/api/client-go to v0.143.2 (#259)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-09 13:03:16 +00:00
renovate[bot]
bccfa93a15
deps: update module gitlab.com/gitlab-org/api/client-go to v0.143.1 (#258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 22:14:06 +00:00
renovate[bot]
3f9108e702
deps: update module gitlab.com/gitlab-org/api/client-go to v0.143.0 (#257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-08 12:07:27 +00:00
renovate[bot]
5ea1798a68
deps: update module github.com/spf13/cobra to v1.10.1 (#256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-06 21:37:52 +00:00
renovate[bot]
de80649838
deps: update module gitlab.com/gitlab-org/api/client-go to v0.142.6 (#255)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-06 21:37:38 +00:00
renovate[bot]
f765abbb92
deps: update dependency go to v1.25.1 (#254)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-09-06 18:51:22 +00:00
renovate[bot]
132f62d82d
deps: update module gitlab.com/gitlab-org/api/client-go to v0.142.5 (#252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-31 10:33:26 +00:00
renovate[bot]
1d2f74752b
deps: update module gitlab.com/gitlab-org/api/client-go to v0.142.4 (#251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-28 07:46:08 +00:00
renovate[bot]
84b3acbe4d
deps: update module github.com/stretchr/testify to v1.11.1 (#250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-27 12:05:21 +00:00
renovate[bot]
c4610b8e5e
deps: update module gitlab.com/gitlab-org/api/client-go to v0.142.2 (#249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-26 18:07:17 +00:00
renovate[bot]
ce56ac0cd1
deps: update module gitlab.com/gitlab-org/api/client-go to v0.142.1 (#248)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-25 05:34:37 +00:00
renovate[bot]
69be23dd6f
deps: update module github.com/stretchr/testify to v1.11.0 (#247)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-24 22:11:38 +00:00
852c08ed3d
ci: fix ko settings after using mise (#246) 2025-08-24 17:39:10 +02:00
e1afa22e0a
ci: mdbooks binary is missing (#245) 2025-08-24 17:16:40 +02:00
f077b647e7
ci: separate renovate manager for toml (#244) 2025-08-24 15:07:58 +00:00
44b76e55f8
ci: allow regex manager in toml files for mise (#243) 2025-08-24 14:56:11 +00:00
e83a7c9a23
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.
2025-08-24 14:52:01 +00:00
renovate[bot]
563885899c
deps: update actions/checkout action to v5 (#239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-24 16:46:49 +02:00
renovate[bot]
16ba2c6b09
deps: update module github.com/google/go-github/v72 to v74 (#241)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-24 16:46:16 +02:00
renovate[bot]
e6c8f3f93b
deps: update actions/upload-pages-artifact action to v4 (#240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-24 16:45:41 +02:00
e6503da93a
refactor(cmd): use factories instead of global cobra command structs (#238)
This enables us to create new commands for e2e tests.
2025-08-24 14:44:05 +00:00
renovate[bot]
5b5b29c0b5
deps: update dependency go to v1.25.0 (#222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-24 13:50:59 +00:00
c768260a2e
chore: use mise to install all tools in CI and locally (#237)
Makes sure that I have the same versions locally as CI
2025-08-24 15:49:23 +02:00
b3cb9e128c
chore(main): release v0.7.0 (#232) 2025-08-23 23:05:30 +02:00
d259921215
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
2025-08-23 23:00:52 +02:00
48b1894cac
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
2025-08-23 22:40:28 +02:00
5306e2dd35
fix: filter out empty updaters in input (#235) 2025-08-23 22:38:24 +02:00
f1aa1a2ef4
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.
2025-08-23 22:14:34 +02:00
Mattis Krämer
1e9e0aa5d9
feat: add updater for package.json (#213) 2025-08-23 22:05:52 +02:00
renovate[bot]
6237c9b666
deps: update codecov/codecov-action digest to fdcc847 (#229)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-23 20:23:41 +02:00
renovate[bot]
2f7e8b9afe
deps: update actions/checkout digest to 08eba0b (#220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-23 20:23:12 +02:00
renovate[bot]
73d9c877b0
deps: update module gitlab.com/gitlab-org/api/client-go to v0.142.0 (#231)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-21 17:57:43 +00:00
renovate[bot]
014ec7b723
deps: update module gitlab.com/gitlab-org/api/client-go to v0.141.2 (#230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-20 17:46:15 +00:00
renovate[bot]
dbde726d15
deps: update module gitlab.com/gitlab-org/api/client-go to v0.141.1 (#228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 22:53:08 +00:00
renovate[bot]
6e2c754376
deps: update module gitlab.com/gitlab-org/api/client-go to v0.140.0 (#227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 17:25:07 +00:00
renovate[bot]
eb6c687737
deps: update module gitlab.com/gitlab-org/api/client-go to v0.139.2 (#226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 10:09:38 +00:00
renovate[bot]
2fe0f0e5b6
deps: update dependency golangci/golangci-lint to v2.4.0 (#224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 00:57:48 +00:00
renovate[bot]
fd903e056c
deps: update module gitlab.com/gitlab-org/api/client-go to v0.139.0 (#223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-13 09:51:56 +00:00
renovate[bot]
763a5defac
deps: update module gitlab.com/gitlab-org/api/client-go to v0.138.0 (#221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-12 13:44:44 +00:00
renovate[bot]
e73bf82a92
deps: update dependency go to v1.24.6 (#219)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-06 21:26:34 +00:00
renovate[bot]
1fad5e6264
deps: update dependency golangci/golangci-lint to v2.3.1 (#216)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-03 00:47:54 +00:00
renovate[bot]
785c29deb2
deps: update module github.com/yuin/goldmark to v1.7.13 (#218)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-22 21:14:29 +00:00
renovate[bot]
273107b9af
deps: update module gitlab.com/gitlab-org/api/client-go to v0.137.0 (#217)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-21 22:36:38 +00:00
renovate[bot]
64874f9089
deps: update dependency rust-lang/mdbook to v0.4.52 (#214)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-15 02:12:30 +00:00
renovate[bot]
942aa80aa9
deps: update dependency golangci/golangci-lint to v2.2.2 (#212)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-11 16:02:05 +00:00
8eb7eadc4e
chore(main): release v0.6.1 (#211) 2025-07-11 11:32:09 +02:00
Zadkiel AHARONIAN
bcca36e856
fix(gitlab): support fast-forward merges (#210)
This change allows detecting that the releaser-pleaser PR is well merged. 

As of today, it fails with "ERR failed to create pending releases: pull request is missing the merge commit".
2025-07-11 11:23:21 +02:00
renovate[bot]
75fe90ab6e
deps: update dependency go to v1.24.5 (#209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-09 00:02:06 +00:00
renovate[bot]
3e77b7e0d9
deps: update module gitlab.com/gitlab-org/api/client-go to v0.134.0 (#208)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-07 16:50:20 +00:00
renovate[bot]
a7347bc191
deps: update module gitlab.com/gitlab-org/api/client-go to v0.133.0 (#206)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-03 12:13:44 +00:00
renovate[bot]
6e97e0d601
deps: update module gitlab.com/gitlab-org/api/client-go to v0.132.0 (#205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-02 16:10:25 +00:00
renovate[bot]
48b8696efc
deps: update module gitlab.com/gitlab-org/api/client-go to v0.131.0 (#204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-01 19:53:10 +00:00
renovate[bot]
dfe39868ac
deps: update dependency golangci/golangci-lint to v2.2.1 (#203)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-29 22:15:32 +00:00
renovate[bot]
5a273f9ab5
deps: update dependency golangci/golangci-lint to v2.2.0 (#202)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-28 21:42:14 +00:00
fc1ee70c28
chore(main): release v0.6.0 (#189) 2025-06-14 16:47:17 +02:00
0de242a4e6
ci: only build single platform for local releaser-pleaser jobs (#200)
The image is never pushed and only executed on linux/amd64 hosts, so
building linux/arm64 is a waste of time and resources.
2025-06-14 14:34:26 +00:00
d540e2221d
docs: describe concurrency and conflict considerations (#199)
Describe the issue with concurrency, the global state and what went into
the recent changes in #196, #197 and #198.
2025-06-14 14:24:16 +00:00
2d3a960939
feat: run one job concurrently to reduce chance of conflicts (#198)
Each run of releaser-pleaser acts on the same global state in the
forge. Therefore, parallel runs are unnecessary.

This commit also communicates to the GitHub and GitLab CI pipelines that
the releaser-pleaser jobs can be cancelled as early as possible.

- On GitHub Actions this can be guaranteed through the workflow
  settings. These settings are copied into each repository that uses
  releaser-pleaser, so users need to update this manually. I will add a
  note to the release notes for this.
- On GitLab CI/CD this requires the user to configure a project level setting to
  "auto-cancel redundant pipelines". We will not recommend user to set
  this, as it is quite invasive and can break their regular CI pipelines.
2025-06-14 13:43:35 +00:00
d24ae7de98
feat: detect changed pull request description and retry process (#197)
If the release PR description was changed by a human after
releaser-pleaser fetched the PR for the first time, releaser-pleaser
would revert the users changes accidentally.

This commit introduces an additional check right before updating the
pull request description, to make sure we do not accidentally loose user
changes. There is still the potential for a conflict in between us
checking the description is the same, and updating the description. The
time window for this should be reduced from multiple seconds-minutes to
a few hundred milliseconds at most.

In case a conflict is detected, we retry the whole process up to 2
times, to make sure that the users changes are reflected as soon as
possible. This is especially important on GitLab CI/CD because a changed
pull (merge) request description does not cause another job to run.

With this change, the branch is still pushed, as the user is not
expected to make any changes to it.

Fixes #151
2025-06-14 13:23:05 +00:00
08d35f2f57
feat: graceful shutdown when CI job is cancelled (#196)
By listening on SIGINT and SIGTERM signals we can stop executing as soon
as reasonably possible. This helps to avoid uncessary work and stop the
job earlier.

Right now we have no manual checks for cancelled contexts, and rely on
the http client to check for it while making requests.
2025-06-14 13:19:34 +00:00
eae0045359
feat: colorize log output (#195)
Makes it easier to read, uses lmittmann/tint.
2025-06-14 13:11:28 +00:00
50b2762dca
fix: missing push when files were changed (#193)
The whole check to avoid pushes when they were only rebases was broken
and compared the wrong things. Unfortunately this worked for nearly all
unit tests, except for one were I used the wrong assertion.

This fixed the check by comparing the right things and inverting the
assertion in the unit test to make sure things do not break again in the
future.

Bug was introduced in #114.
2025-06-14 13:03:04 +02:00
renovate[bot]
983162d26e
deps: update module gitlab.com/gitlab-org/api/client-go to v0.130.1 (#192)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-12 03:44:02 +00:00
renovate[bot]
e6e9779e87
deps: update module gitlab.com/gitlab-org/api/client-go to v0.130.0 (#191)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-11 16:54:03 +00:00
5f1849106c
fix: crash when running in repo without any tags (#190)
Recent changes in v0.5.1 introduced a bug that caused releaser-pleaser
to crash when running in a repository that contained no tags at all.
This fixes the issue by checking if there is a tag before using it in
the logger.

Bug was introduced in #174.
2025-06-09 11:22:27 +02:00
81a855f5ab
feat: avoid pushing release branch only for rebasing (#114)
Right now releaser-pleaser pushes the branch even if it is only for a "rebase",
this wastes CI resources. Instead, it should only push when there are changes
to the files it owns.

- **Old**: Push when there is a diff origin/release-pr..release-pr
- **New**: Push when the these two diffs are not the same:
  
      origin/main..release-pr
      $(git merge-base origin/main origin/release-pr)..release-pr

Closes #92
2025-06-09 10:52:09 +02:00
175d6d0633
feat: real user as commit author (#187)
Previously all commits were authored and committed by

    releaser-pleaser <>

This looked weird when looking at the commit. We now check with the
Forge API for details on the currently authenticated user, and use that
name and email as the commit author. The commit committer stays the same
for now.

In GitHub, the default `$GITHUB_TOKEN` does not allow access to the
required endpoint, so for github the user `github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>` is hardcoded
when the request fails.
2025-06-09 08:06:56 +00:00
renovate[bot]
f2786c8f39
deps: update module github.com/go-git/go-git/v5 to v5.16.2 (#188)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-09 07:06:06 +00:00
1779356543
deps: replace xanzy/go-gitlab with official client (#182) 2025-06-07 16:42:29 +00:00
renovate[bot]
ad13dc24e0
chore(config): migrate config .github/renovate.json5 (#186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 16:40:56 +00:00
renovate[bot]
d91d93fc8c
deps: update codecov/codecov-action digest to 18283e0 (#113)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 18:39:31 +02:00
renovate[bot]
e3ecd8993c
chore: update golangci-lint to v2 and fix breakage (#184)
deps: update golangci/golangci-lint-action action to v8

Co-authored-by: Julian Tölle <julian.toelle97@gmail.com>
2025-06-07 16:39:18 +00:00
renovate[bot]
49855aa700
deps: update module github.com/google/go-github/v66 to v72 (#185)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 18:39:05 +02:00
renovate[bot]
f49481cd92
deps: update golangci/golangci-lint-action digest to 55c2c14 (#135)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 18:24:36 +02:00
renovate[bot]
359912dcc0
deps: update actions/setup-go digest to d35c59a (#122)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-07 18:24:24 +02:00
renovate[bot]
1f882bf014
deps: update dependency go to v1.24.4 (#181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-06 22:07:04 +00:00
renovate[bot]
31b12b9c33
deps: update module github.com/go-git/go-git/v5 to v5.16.1 (#180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-04 12:24:23 +00:00
renovate[bot]
9c8b854de0
deps: update registry.gitlab.com/gitlab-org/release-cli docker tag to v0.24.0 (#179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-02 21:27:02 +00:00
renovate[bot]
c1b0f15e07
deps: update dependency rust-lang/mdbook to v0.4.51 (#177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-26 23:34:42 +00:00
renovate[bot]
58a9f1c9d5
deps: update dependency rust-lang/mdbook to v0.4.50 (#176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-23 16:12:43 +00:00
fe3c9488b3
chore(main): release v0.5.1 (#175)
### Bug Fixes

- invalid version for subsequent pre-releases (#174)
2025-05-22 15:38:41 +02:00
d9c081d280
fix: invalid version for subsequent pre-releases (#174)
If two pre-releases were cut in a row, the second pre-release version
would only consider the semantic changes since the previous pre-release,
but base its new version of the latest tag.

Example:

- stable tag: `1.2.0`
- latest tag: `1.3.0-rc.1`
- 1 commit since with `fix:` tag

The resulting releaser-pleaser tag would be: `1.2.1-rc.2`. It should be
`1.3.0-rc.2` instead.

This is now fixed by considering different commit ranges for versioning
and changelog.

For a stable version we want the list of changes since the stable tag.

For a prerelease version we want the list of changes since the latest
tag. To avoid repeating the same features over and over in a series of
multiple pre-releases.

This behaviour already existed and worked.

For a stable version, we want to consider all changes since the stable
tag.

For a prerelease version, we also want to consider all changes since the
stable tag. This was broken and only used the changes since the latest
tag.
2025-05-22 15:27:49 +02:00
renovate[bot]
9c95dd558b
deps: update module github.com/yuin/goldmark to v1.7.12 (#173)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-17 14:40:46 +00:00
renovate[bot]
c31e40d04b
deps: update dependency go to v1.24.3 (#172)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-06 19:20:16 +00:00
renovate[bot]
d93378a72e
deps: update dependency rust-lang/mdbook to v0.4.49 (#171)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-05 23:23:44 +00:00
renovate[bot]
86207b80f2
deps: update module github.com/yuin/goldmark to v1.7.11 (#170)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-27 14:32:40 +00:00
renovate[bot]
b658a3a531
deps: update module github.com/teekennedy/goldmark-markdown to v0.5.1 (#169)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-24 01:58:58 +00:00
renovate[bot]
85ff2126b1
deps: update module github.com/go-git/go-git/v5 to v5.16.0 (#168)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 22:53:49 +00:00
renovate[bot]
377ec44cd3
deps: update module github.com/yuin/goldmark to v1.7.10 (#167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-15 22:53:41 +00:00
renovate[bot]
7fe19174db
deps: update module github.com/yuin/goldmark to v1.7.9 (#166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-14 22:44:55 +00:00
renovate[bot]
3ed3a1856c
deps: update ko-build/setup-ko action to v0.9 (#165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-11 19:46:37 +00:00
renovate[bot]
9d0cfc7c83
deps: update module github.com/go-git/go-git/v5 to v5.15.0 (#164)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-11 10:45:01 +00:00
renovate[bot]
1bb296a509
deps: update dependency go to v1.24.2 (#163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-04-01 19:11:37 +00:00
renovate[bot]
00986532b9
deps: update dependency rust-lang/mdbook to v0.4.48 (#162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-31 23:15:13 +00:00
renovate[bot]
f9ba6daa42
deps: update registry.gitlab.com/gitlab-org/release-cli docker tag to v0.23.0 (#161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-20 16:33:00 +00:00
renovate[bot]
1be7bf0f76
deps: update dependency golangci/golangci-lint to v1.64.8 (#159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-17 23:26:21 +00:00
renovate[bot]
a594ac0373
deps: update module golang.org/x/net to v0.36.0 [security] (#158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-13 15:12:14 +00:00
renovate[bot]
98e60583a4
deps: update dependency golangci/golangci-lint to v1.64.7 (#157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-12 01:27:44 +00:00
renovate[bot]
4dd80c9492
deps: update dependency rust-lang/mdbook to v0.4.47 (#156)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-09 18:23:34 +00:00
renovate[bot]
d6262d8ecb
deps: update dependency rust-lang/mdbook to v0.4.46 (#155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-08 22:38:23 +00:00
renovate[bot]
6c57ad0fbb
deps: update dependency go to v1.24.1 (#154)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-04 22:53:53 +00:00
renovate[bot]
d8841d3fab
deps: update dependency golangci/golangci-lint to v1.64.6 (#153)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-03 01:50:07 +00:00
renovate[bot]
e795d16489
deps: update module github.com/go-git/go-git/v5 to v5.14.0 (#152)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-27 16:28:51 +00:00
renovate[bot]
d22f87ecc2
deps: update dependency rust-lang/mdbook to v0.4.45 (#150)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 18:49:35 +00:00
renovate[bot]
761eede169
deps: update registry.gitlab.com/gitlab-org/release-cli docker tag to v0.22.0 (#149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 15:39:48 +00:00
renovate[bot]
510f62f75d
deps: update module github.com/spf13/cobra to v1.9.1 (#148)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-17 02:51:50 +00:00
renovate[bot]
da66bd0cc4
deps: update module github.com/spf13/cobra to v1.9.0 (#147)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-15 22:42:09 +00:00
renovate[bot]
11c61e9dbd
deps: update dependency golangci/golangci-lint to v1.64.5 (#146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-14 01:52:19 +00:00
renovate[bot]
a54d44673d
deps: update dependency golangci/golangci-lint to v1.64.4 (#145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-13 02:46:20 +00:00
renovate[bot]
b2a1754432
deps: update dependency golangci/golangci-lint to v1.64.3 (#144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 21:50:04 +00:00
renovate[bot]
871f69acbe
deps: update dependency go to v1.24.0 (#142)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 06:55:41 +00:00
renovate[bot]
34ca528570
deps: update dependency golangci/golangci-lint to v1.64.2 (#143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-12 02:49:42 +00:00
renovate[bot]
7a92e82d94
deps: update dependency go to v1.23.6 (#141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-04 20:36:25 +00:00
renovate[bot]
6802aad634
deps: update dependency rust-lang/mdbook to v0.4.44 (#140)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-28 20:52:41 +00:00
renovate[bot]
0774353639
deps: update module github.com/go-git/go-git/v5 to v5.13.2 (#139)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-23 01:57:18 +00:00
renovate[bot]
2526149c16
deps: update registry.gitlab.com/gitlab-org/release-cli docker tag to v0.21.0 (#138)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-21 17:53:22 +00:00
renovate[bot]
28a71f54d4
deps: update dependency go to v1.23.5 (#136)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-16 21:25:30 +00:00
renovate[bot]
42062bf401
deps: update ko-build/setup-ko action to v0.8 (#134)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-15 18:15:28 +00:00
renovate[bot]
23bf944b6d
deps: update dependency golangci/golangci-lint to v1.63.4 (#133)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-03 21:21:29 +00:00
renovate[bot]
cc69c719cb
deps: update dependency golangci/golangci-lint to v1.63.3 (#132)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 21:21:28 +00:00
renovate[bot]
da6fe2a380
deps: update module github.com/go-git/go-git/v5 to v5.13.1 (#131)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 19:21:43 +00:00
renovate[bot]
d26d068626
deps: update dependency golangci/golangci-lint to v1.63.2 (#130)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-02 13:46:17 +00:00
renovate[bot]
16006339a1
deps: update dependency golangci/golangci-lint to v1.63.1 (#129)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-01 22:31:55 +00:00
renovate[bot]
a85e3a17e5
deps: update dependency golangci/golangci-lint to v1.63.0 (#128)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-01 16:01:09 +00:00
renovate[bot]
928472b7eb
deps: update module github.com/go-git/go-git/v5 to v5.13.0 (#127)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-29 00:22:02 +00:00
renovate[bot]
57d2ab8b26
deps: update module golang.org/x/net to v0.33.0 [security] (#126)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-22 16:55:47 +00:00
renovate[bot]
4e784f2ccc
deps: update module golang.org/x/crypto to v0.31.0 [security] (#124)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 00:48:12 +00:00
renovate[bot]
093f0f97bd
deps: update module github.com/teekennedy/goldmark-markdown to v0.4.1 (#125)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-15 00:47:46 +00:00
renovate[bot]
e7db5b2e66
deps: update module github.com/teekennedy/goldmark-markdown to v0.4.0 (#123)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-14 21:50:52 +00:00
renovate[bot]
4af9f88382
deps: update registry.gitlab.com/gitlab-org/release-cli docker tag to v0.20.0 (#119)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-12-04 01:30:33 +00:00
renovate[bot]
0ad9250a6a
deps: update dependency rust-lang/mdbook to v0.4.43 (#117)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-25 20:07:39 +00:00
renovate[bot]
25c6cffa76
deps: update dependency golangci/golangci-lint to v1.62.2 (#116)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-25 16:57:01 +00:00
renovate[bot]
05b492fa0e
deps: update module github.com/stretchr/testify to v1.10.0 (#115)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-23 13:56:11 +00:00
renovate[bot]
b1180a17ba
deps: update codecov/codecov-action digest to 288befb (#112)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-20 22:04:38 +01:00
renovate[bot]
b5819a2c6a
deps: update module github.com/xanzy/go-gitlab to v0.114.0 (#111)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-18 23:12:38 +00:00
b617232797
chore(main): release v0.5.0 (#107)
Co-authored-by: releaser-pleaser <>
2024-11-15 18:54:56 +01:00
dd166ec446
feat(github): mark pre-releases correctly (#110)
In theory every forge can support this, but right now only GitHub allows
one to define a release as "pre-release".

Closes #45
2024-11-15 18:49:50 +01:00
ef1d92cff0
refactor: interface for versioning strategy (#109) 2024-11-15 17:43:40 +00:00
11f8403241
fix: create CHANGELOG.md if it does not exist (#108)
During a previous refactoring (#15) the Changelog generation logic stopped creating the file if it did not exist. This makes sure that the file actually gets created. This is primarily required while onboarding new repositories.

Closes #85
2024-11-15 18:07:59 +01:00
e9b3ba8ea2
feat(gitlab): make job dependencies configurable and run immediately (#101)
In the CI/CD component, make the jobs `needs` setting configurable
through an input and change the default to `[]`. This will cause the job
to run immediately.

Co-authored-by: jo <ljonas@riseup.net>
2024-11-15 17:51:54 +01:00
renovate[bot]
6c5bdfeee8
deps: update codecov/codecov-action digest to 5c47607 (#106)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-15 17:46:41 +01:00
0ae2d909bc
fix: use commits with slightly invalid messages in release notes (#105)
Fixes a bug where commits with messages that do not strictly conform to
conventional commits spec would be ignored. This could easily happen
while parsing footers like "Closes #xx" which would start the footer
section, while continuing with the body afterwards.

This solution has two downsides:

- these warnings are not surfaced to the user.
- If a `BREAKING CHANGE` footer exists after the parsing issue it is ignored

Co-authored-by: jo <ljonas@riseup.net>
2024-11-15 17:25:15 +01:00
renovate[bot]
faf28fd314
deps: update module github.com/google/go-github/v65 to v66 (#103)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-15 15:10:52 +01:00
renovate[bot]
147148c891
chore(config): migrate config .github/renovate.json5 (#104)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-15 15:10:33 +01:00
renovate[bot]
79c5b13e1f
deps: update codecov/codecov-action action to v5 (#102)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-15 15:09:17 +01:00
renovate[bot]
b6d6270d9e
deps: update actions/checkout digest to 11bd719 (#89)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-15 14:52:43 +01:00
renovate[bot]
59aa7aae02
deps: update actions/setup-go digest to 41dfa10 (#90)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-15 14:52:20 +01:00
renovate[bot]
b55cba293f
deps: update dependency golangci/golangci-lint to v1.62.0 (#98)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-10 22:51:51 +00:00
05be3684c6
chore(main): release v0.4.2 (#97)
Co-authored-by: releaser-pleaser <>
2024-11-08 13:28:52 +01:00
cbfacc894b
fix(action): container image reference used wrong syntax (#96)
The current value caused the following error when running the action in
a different repository:

    Error: 'ghcr.io/apricote/releaser-pleaser:v0.4.1' should be either '[path]/Dockerfile' or 'docker://image[:tag]'.

Not sure why this did not come up before, as we are also using the same
format for the CI in this repository, even if we use another tag.
2024-11-08 12:27:09 +00:00
renovate[bot]
71351140f6
deps: update dependency rust-lang/mdbook to v0.4.42 (#95)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-07 17:23:27 +00:00
renovate[bot]
8c7b9fcf93
deps: update dependency rust-lang/mdbook to v0.4.41 (#94)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-06 19:22:01 +00:00
renovate[bot]
db4aebcc73
deps: update module github.com/xanzy/go-gitlab to v0.113.0 (#93)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-11-05 16:50:37 +00:00
renovate[bot]
8493c5a625
deps: update registry.gitlab.com/gitlab-org/release-cli docker tag to v0.19.0 (#88)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-21 06:09:18 +00:00
1883466c3e
chore(main): release v0.4.1 (#87)
Co-authored-by: releaser-pleaser <>
2024-10-17 19:24:24 +02:00
3caa7364ee
fix(gitlab): release not created when release pr was squashed (#86)
When the release pull request was squashed on GitLab, the release
creation fails with error

    Error: failed to create pending releases: pull request is missing the merge commit

This fixes the problem by checking both `MergeCommitSHA` and
`SquashCommitSHA` and using whichever is set.
2024-10-17 17:22:03 +00:00
renovate[bot]
763018ff9b
deps: update module github.com/yuin/goldmark to v1.7.8 (#84)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-16 12:43:19 +00:00
renovate[bot]
acff7ea830
deps: update module github.com/yuin/goldmark to v1.7.7 (#83)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-15 11:11:20 +00:00
renovate[bot]
2a4f2b97d1
deps: update module github.com/xanzy/go-gitlab to v0.112.0 (#82)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 18:42:23 +00:00
renovate[bot]
755d9b125b
deps: update actions/checkout digest to eef6144 (#79)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 15:02:39 +02:00
renovate[bot]
de4f26225a
deps: update golangci/golangci-lint-action digest to 971e284 (#77)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 15:01:17 +02:00
renovate[bot]
da6257e618
deps: update codecov/codecov-action digest to b9fd7d1 (#76)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-14 14:19:09 +02:00
renovate[bot]
cd412ba59f
deps: update module github.com/yuin/goldmark to v1.7.6 (#81)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-12 15:21:35 +00:00
renovate[bot]
40a15dfafa
deps: update module github.com/xanzy/go-gitlab to v0.111.0 (#80)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-10 18:21:50 +00:00
renovate[bot]
9bb117c7b9
deps: update module github.com/xanzy/go-gitlab to v0.110.0 (#78)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-06 13:10:54 +00:00
4cc45ea244
chore(main): release v0.4.0 (#62)
Co-authored-by: releaser-pleaser <>
2024-09-25 13:06:06 +02:00
89dc9e3fe8
feat(gitlab): support self-managed instances (#75)
Support self-managed gitlab instances by reading the GitLab CI environment
variables.
2024-09-25 12:41:11 +02:00
55083f2a59
docs: guide for extra-files (#74) 2024-09-25 12:09:27 +02:00
08505a55cd
ci(mirror): fix missing lfs files on push (#73) 2024-09-25 09:35:59 +00:00
1a370c39dc
docs: GitLab tutorial and CI/CD component reference (#72) 2024-09-25 11:23:04 +02:00
2621c48d75
feat(changelog): omit version heading in forge release notes
The forge ui usually shows the release name right above the description,
so this removes an unecessary duplicate bit of information.

In addition this also cleans up the changelog interface a bit and moves
functionality where it belongs. Prepares a bit for custom changelogs in
the future.

Closes #32
2024-09-22 14:00:30 +02:00
renovate[bot]
997b6492de
deps: update dependency golangci/golangci-lint to v1.61.0 (#70)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-21 11:44:03 +00:00
renovate[bot]
937b885696
deps: update module github.com/google/go-github/v63 to v65 (#69)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-21 13:40:15 +02:00
renovate[bot]
4402612538
deps: update actions/configure-pages action to v5 (#68)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-21 13:37:03 +02:00
renovate[bot]
6a2f536650
deps: pin dependencies (#66)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-21 13:35:27 +02:00
renovate[bot]
90685994d7
deps: update module google.golang.org/protobuf to v1.33.0 [security] (#65)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-09-21 11:33:15 +00:00
7bd752c2f5
chore: setup renovate (#64) 2024-09-21 13:29:26 +02:00
dc1903c4b4
chore(main): release v0.4.0-beta.1 (#56)
Co-authored-by: releaser-pleaser <>
2024-09-15 21:00:06 +02:00
2567293368
fix: multiple extra-files are not evaluated properly (#61)
Quoting issues caused multiple extra-files to be ignored.
2024-09-15 20:59:17 +02:00
7a3d46eac7
ci: update version reference in gitlab ci/cd component (#60) 2024-09-15 18:58:38 +02:00
8d7b1c9580
feat(gitlab): add CI/CD component (#55)
This adds a GitLab CI/CD component that can be `included` in users
GitLab CI configuration to integrate releaser-pleaser.

Unlike the GitHub Action, this can not easily run whenever a merge
request description is changed, only when changes are pushed to main.
2024-09-15 18:54:38 +02:00
61cf12a052
feat: add shell to container image (#59)
Images used in GitLab CI need to have a shell included, otherwise the
job could not be started.

This replaces the default `cgr.dev/chainguard/static` base image with
`cgr.dev/chainguard/busybox`. Busybox is the smallest image that
includes a functional shell.

Needed for #55.
2024-09-15 18:46:45 +02:00
7b49e8ea0c
ci: job name may not contain dot (#58) 2024-09-15 18:43:16 +02:00
da0c07497b
ci: push all changes to gitlab.com mirror (#57)
GitLab only considers repos on the current instance for its CI/CD
catalog. We want to publish a GitLab CI/CD component for #4.
2024-09-15 18:41:32 +02:00
84d4dd9d26
chore(main): release v0.4.0-beta.0 (#50)
Co-authored-by: releaser-pleaser <>
2024-09-15 17:18:24 +02:00
ee83cec049
fix(gitlab): use project path wherever possible (#54)
Turns out that all we need is the path, and not the project id. The path
is way more user friendly, and we can easily get it from a CI variable
or combine it from the namespace & project name.
2024-09-08 21:07:03 +02:00
634eac3b76
fix(parser): invalid handling of empty lines (#53)
GitLab generates commit messages of the pattern "scope: message\n" if no
body is present. This throws up the conventional commits parser we use,
and results in the error message "missing a blank line".

We now `strings.TrimSpace()` the commit message to avoid this problem.
2024-09-08 21:05:18 +02:00
ee5c7aa142
fix(cli): command name in help output (#52) 2024-09-08 21:01:46 +02:00
2fba5414e5
fix(gitlab): hardcoded project id (#51)
The GitLab project ID was still hardcoded to my playground project on
GitLab.com.

This commit instead reads from the predefined GitLab CI/CD variable for
the projects ID (`CI_PROJECT_ID`).
2024-09-08 20:03:19 +02:00
48d9ede0a2
feat: add support for GitLab repositories (#49) 2024-09-07 21:54:25 +02:00
5ea41654a7
fix(parser): continue on unparsable commit message (#48)
We should not fail the whole process if a single commit message is
unparsable.

Instead we now log the issue and ignore the commit.
2024-09-07 21:51:15 +02:00
2010ac1143
refactor(github): add pagination helper (#47) 2024-09-07 21:36:17 +02:00
af505c94c6
refactor: labels as structs with descriptions (#46) 2024-09-07 21:33:28 +02:00
0a199e693f
chore(main): release v0.3.0 (#38)
Co-authored-by: releaser-pleaser <>
2024-09-06 23:46:04 +02:00
b9dd0f986c
feat(cli): show release PR url in log messages (#44)
Makes navigating to the PR way easier when looking at the logs.

This requires a new `Forge.PullRequestURL()` method that needs to be
implemented for all forges.
2024-09-06 23:27:48 +02:00
2effe5e72d
feat: edit commit message after merging through PR (#43)
Closes #5
2024-09-06 23:17:06 +02:00
36a0b90bcd
refactor: releasepr markdown handling (#42) 2024-09-01 14:19:13 +02:00
0750bd6b46
feat: format markdown in changelog entry (#41) 2024-08-31 22:23:01 +02:00
4cb22eae10
refactor: replace markdown renderer (#40)
The new renderer is actually published as a module and can be extended
through the usual goldmark extensions.
2024-08-31 16:49:07 +02:00
a0a064d387
refactor: move things to packages (#39) 2024-08-31 15:23:21 +02:00
44184a77f9 refactor: remove unwanted log 2024-08-30 19:47:12 +02:00
971b6e6ef7
feat: less repetitive entries for for prerelease changelogs (#37)
Right now we always show all new releasable commits since the last
_stable_ release.

If a project publishes multiple pre-releases in a row, they all repeat
the same lines with a few new additions.

On the other hand, when we cut a stable release, we do want to show all
changes since the last stable release, as we can not expect users to
read the changelog of pre-releases.

As a compromise, the code now looks at the type of release that is being
created, and decides based on that if we will look at STABLE..HEAD
(stable release) or LATEST..HEAD (pre-release).

Close #33
2024-08-30 19:44:51 +02:00
693ca21e32
refactor: load existing release pr earlier (#36)
We need information from the release pr for the following steps, as the
user can override various behaviours by commenting/labeling the release
pr.
2024-08-30 19:27:42 +02:00
1f39df03c5
chore(main): release v0.2.0 (#24)
Co-authored-by: releaser-pleaser <>
2024-08-25 23:01:48 +02:00
57a1d80600 docs: add link to github repo 2024-08-25 22:31:39 +02:00
499f127b9e
docs: add all current features (#34) 2024-08-25 22:13:56 +02:00
2567f0ae8b
ci: run on pr updates from main branch (#30)
With `pull_request`, we run in the context of the pull request branch.

- This means we run with the code from the PR branch, possibly breaking
  the current release PR for this repo with in-progress, unreviewed changes.
- This means that the secret is not available on Pull Requests from
  forks.

Switching to `pull_request_target` means we always run in the scope of
the original repository. The secret is available and the code is checked
out from our main branch.

`pull_request_target` has security considerations, but they do not apply
here as we do not check out or run code from the (external, malicious) PR.
2024-08-25 17:16:43 +02:00
2cd73a8679
ci: use current code for releaser-pleaser action (#28)
The previous job always used the last release version of
releaser-pleaser. This caused two issues:

- if new flags were added to `action.yml` since the last release, the
  program errored because the flags are unknown.
- right after merging a release pr, the image reference was already
  updated, but no new container image was built yet.

This fixes both issues, by using a locally built version of
releaser-pleaser, which is always up-to-date and available.
2024-08-25 13:54:21 +02:00
1ede0bef10
feat(cli): add --version flag (#29) 2024-08-25 13:47:55 +02:00
a67b510284
docs: setup mdbook (#27) 2024-08-24 16:23:42 +02:00
3e51dd8495
chore: update version in action.yml (#26) 2024-08-24 15:44:28 +02:00
a841447063
fix(action): invalid quoting for extra-files arg (#25) 2024-08-24 15:39:55 +02:00
120 changed files with 7037 additions and 3221 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.png filter=lfs diff=lfs merge=lfs -text

86
.github/renovate.json5 vendored Normal file
View file

@ -0,0 +1,86 @@
{
extends: [
':semanticCommits',
':semanticCommitTypeAll(deps)',
':semanticCommitScopeDisabled',
':dependencyDashboard',
':approveMajorUpdates',
':automergeMinor',
':automergeLinters',
':automergeTesters',
':automergeTypes',
':maintainLockFilesWeekly',
':enableVulnerabilityAlerts',
'helpers:pinGitHubActionDigests',
],
packageRules: [
{
groupName: 'linters',
matchUpdateTypes: [
'minor',
'patch',
],
matchDepNames: [
'golangci/golangci-lint',
],
automerge: true,
},
{
groupName: 'testing',
matchUpdateTypes: [
'minor',
'patch',
],
matchDepNames: [
'github.com/stretchr/testify',
],
automerge: true,
},
{
groupName: 'github-actions',
matchUpdateTypes: [
'minor',
'patch',
],
matchDepTypes: [
'action',
],
automerge: true,
},
{
groupName: 'gitlab-ci',
matchUpdateTypes: [
'minor',
'patch',
],
matchPackageNames: [
'registry.gitlab.com/gitlab-org/release-cli',
],
automerge: true,
},
],
customManagers: [
{
customType: 'regex',
managerFilePatterns: [
'/.+\\.ya?ml$/',
],
matchStrings: [
': (?<currentValue>.+) # renovate: datasource=(?<datasource>[a-z-]+) depName=(?<depName>[^\\s]+)(?: lookupName=(?<packageName>[^\\s]+))?(?: versioning=(?<versioning>[a-z-]+))?(?: extractVersion=(?<extractVersion>[^\\s]+))?',
],
},
{
customType: 'regex',
managerFilePatterns: [
'/.+\\.toml$/'
],
matchStrings: [
'= "(?<currentValue>.+)" # renovate: datasource=(?<datasource>[a-z-]+) depName=(?<depName>[^\\s]+)(?: lookupName=(?<packageName>[^\\s]+))?(?: versioning=(?<versioning>[a-z-]+))?(?: extractVersion=(?<extractVersion>[^\\s]+))?',
],
}
],
postUpdateOptions: [
'gomodUpdateImportPaths',
'gomodTidy',
],
}

View file

@ -2,58 +2,76 @@ name: ci
on:
push:
branches: [main]
branches: [ main ]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
with:
version: v1.60.1 # renovate: datasource=github-releases depName=golangci/golangci-lint
install-mode: none
args: --timeout 5m
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Go
uses: actions/setup-go@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 ./...
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: unit
test-e2e-forgejo:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- name: Set up Go
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5
with:
go-version-file: go.mod
# We can not use "jobs.<job>.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@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
flags: e2e
go-mod-tidy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Run go mod tidy
run: go mod tidy

37
.github/workflows/docs.yaml vendored Normal file
View file

@ -0,0 +1,37 @@
name: docs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write # To push a branch
pages: write # To push to a GitHub Pages site
id-token: write # To update the deployment status
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
lfs: "true"
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Build Book
working-directory: docs
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
# Upload entire repository
path: "docs/book"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

30
.github/workflows/mirror.yaml vendored Normal file
View file

@ -0,0 +1,30 @@
name: mirror
on:
push:
branches: [main]
tags: ["*"]
jobs:
gitlab-com:
runs-on: ubuntu-latest
env:
REMOTE: mirror
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
# Need all to fetch all tags so we can push them
fetch-depth: 0
# Required so they can be pushed too
lfs: true
- name: Add Remote
env:
CLONE_URL: "https://releaser-pleaser:${{ secrets.GITLAB_COM_PUSH_TOKEN }}@gitlab.com/apricote/releaser-pleaser.git"
run: git remote add $REMOTE $CLONE_URL
- name: Push Branches
run: git push --force --all --verbose $REMOTE
- name: Push Tags
run: git push --force --tags --verbose $REMOTE

View file

@ -14,12 +14,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Prepare ko
run: |
echo "${{ github.token }}" | ko login ghcr.io --username "dummy" --password-stdin
repo=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
echo "KO_DOCKER_REPO=ghcr.io/${repo}"
echo "KO_DOCKER_REPO=ghcr.io/${repo}" >> $GITHUB_ENV
- uses: ko-build/setup-ko@v0.7
- run: ko build --bare --tags ${{ github.ref_name }} github.com/apricote/releaser-pleaser/cmd/rp

View file

@ -2,23 +2,47 @@ name: releaser-pleaser
on:
push:
branches: [main]
pull_request:
branches: [ main ]
# Using pull_request_target to avoid tainting the actual release PR with code from open feature pull requests
pull_request_target:
types:
- edited
- labeled
- unlabeled
permissions: {}
# Only one job needs to run at a time, if a new job is started there is probably new data to include in the response, so
# it does not make sense to finish the previous job. This also helps with "data-race conflicts", where a human changes
# the PR description but releaser-pleaser was already running and overwrites the humans changes.
concurrency:
group: releaser-pleaser
cancel-in-progress: true
permissions: { }
jobs:
releaser-pleaser:
# TODO: if: push or pull_request.closed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: main
- 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.
- run: ko build --bare --local --platform linux/amd64 --tags ci github.com/apricote/releaser-pleaser/cmd/rp
- run: "sed -i 's|image: .*$|image: docker://ko.local:ci|g' action.yml"
# Dogfood the action to make sure it works for users.
- name: releaser-pleaser
uses: ./
with:
token: ${{ secrets.RELEASER_PLEASER_TOKEN }}
extra-files: |
action.yml
templates/run.yml

15
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,15 @@
stages: [ release ]
# For the GitLab CI/CD component to be usable, it needs to be published in
# the CI/CD catalog. This happens on new releases.
# As the primary tagging happens on GitHub, we only react to pushed tags
# and create a corresponding GitLab Release.
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:v0.24.0
script: echo "Creating release $CI_COMMIT_TAG"
rules:
- if: $CI_COMMIT_TAG
release:
tag_name: "$CI_COMMIT_TAG"
description: "$CI_COMMIT_TAG_MESSAGE"

View file

@ -1,14 +1,38 @@
version: "2"
linters:
presets:
- bugs
- error
- import
- metalinter
- module
- unused
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- contextcheck
- durationcheck
- errchkjson
- errorlint
- exhaustive
- gocheckcompilerdirectives
- gochecksumtype
- gocritic
- gomoddirectives
- gomodguard
- gosec
- gosmopolitan
- loggercheck
- makezero
- musttag
- nilerr
- nilnesserr
- noctx
- protogetter
- reassign
- recvcheck
- rowserrcheck
- spancheck
- sqlclosecheck
- testifylint
- unparam
- zerologlint
- revive
disable:
# preset error
@ -18,10 +42,23 @@ linters:
# preset import
- depguard
linters-settings:
gci:
sections:
- standard
- default
- localmodule
settings:
revive:
rules:
- name: exported
disabled: true
gomoddirectives:
replace-allow-list:
- codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2
formatters:
enable:
- gci
- goimports
settings:
gci:
sections:
- standard
- default
- localmodule

View file

@ -1,3 +1,6 @@
defaultPlatforms:
- linux/arm64
- linux/amd64
- linux/amd64
# Need a shell for gitlab-ci
defaultBaseImage: cgr.dev/chainguard/busybox

View file

@ -1,6 +1,189 @@
# Changelog
## [v0.7.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.7.1)
### Bug Fixes
- using code blocks within release-notes (#275)
- no html escaping for changelog template (#277)
## [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
- **gitlab**: support fast-forward merges (#210)
## [v0.6.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.6.0)
### ✨ Highlights
#### Reduced resource usage
`releaser-pleaser` now uses less resources:
- It now skips pushing changes to the release pull request if they are only a rebase.
- The configurations for GitHub Actions and GitLab CI/CD now makes sure that only a single job is running at the same time. On GitHub unnecessary/duplicate jobs are also automatically aborted.
- It handles the stop signals from the CI environment and tries to exit quickly.
\```yaml
concurrency:
group: releaser-pleaser
cancel-in-progress: true
\```
#### Avoid losing manual edits to release pull request
Before, releaser-pleaser was prone to overwriting user changes to the release pull request if they were made after releaser-pleaser already started running. There is now an additional check right before submitting the changes to see if the description changed, and retry if it did.
#### Proper commit authorship
Before, the release commits were created by `releaser-pleaser &lt;&gt;`. This was ugly to look at. We now check for details on the API user used to talk to the forge, and use that users details instead as the commit author. The committer is still `releaser-pleaser`.
### Features
- real user as commit author (#187)
- avoid pushing release branch only for rebasing (#114)
- colorize log output (#195)
- graceful shutdown when CI job is cancelled (#196)
- detect changed pull request description and retry process (#197)
- run one job concurrently to reduce chance of conflicts (#198)
### Bug Fixes
- crash when running in repo without any tags (#190)
## [v0.5.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.5.1)
### Bug Fixes
- invalid version for subsequent pre-releases (#174)
## [v0.5.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.5.0)
### Features
- **gitlab**: make job dependencies configurable and run immediately (#101)
- **github**: mark pre-releases correctly (#110)
### Bug Fixes
- use commits with slightly invalid messages in release notes (#105)
- create CHANGELOG.md if it does not exist (#108)
## [v0.4.2](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.2)
### Bug Fixes
- **action**: container image reference used wrong syntax (#96)
## [v0.4.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.1)
### Bug Fixes
- **gitlab**: release not created when release pr was squashed (#86)
## [v0.4.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0)
### ✨ Highlights
#### GitLab Support
You can now use `releaser-pleaser` with projects hosted on GitLab.com and self-managed GitLab installations. Check out the new [tutorial](https://apricote.github.io/releaser-pleaser/tutorials/gitlab.html) to get started.
### Features
- add support for GitLab repositories (#49)
- add shell to container image (#59)
- **gitlab**: add CI/CD component (#55)
- **changelog**: omit version heading in forge release notes
- **gitlab**: support self-managed instances (#75)
### Bug Fixes
- **parser**: continue on unparsable commit message (#48)
- **cli**: command name in help output (#52)
- **parser**: invalid handling of empty lines (#53)
- multiple extra-files are not evaluated properly (#61)
## [v0.4.0-beta.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0-beta.1)
### Features
- add shell to container image (#59)
- **gitlab**: add CI/CD component (#55)
### Bug Fixes
- multiple extra-files are not evaluated properly (#61)
## [v0.4.0-beta.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0-beta.0)
### Features
- add support for GitLab repositories (#49)
### Bug Fixes
- **parser**: continue on unparsable commit message (#48)
- **cli**: command name in help output (#52)
- **parser**: invalid handling of empty lines (#53)
## [v0.3.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.3.0)
### :sparkles: Highlights
#### Cleaner pre-release Release Notes
From now on if you create multiple pre-releases in a row, the release notes will only include changes since the last pre-release. Once you decide to create a stable release, the release notes will be in comparison to the last stable release.
#### Edit pull request after merging.
You can now edit the message for a pull request after merging by adding a \```rp-commits code block into the pull request body. Learn more in the [Release Notes Guide](https://apricote.github.io/releaser-pleaser/guides/release-notes.html#editing-the-release-notes).
### Features
- less repetitive entries for prerelease changelogs #37
- format markdown in changelog entry (#41)
- edit commit message after merging through PR (#43)
- **cli**: show release PR url in log messages (#44)
## [v0.2.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0)
### Features
- update version references in any files (#14)
- **cli**: add --version flag (#29)
### Bug Fixes
- **ci**: building release image fails (#21)
- **ci**: ko pipeline permissions (#23)
- **action**: invalid quoting for extra-files arg (#25)
## [v0.2.0-beta.2](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0-beta.2)
### Features
- update version references in any files (#14)
@ -11,6 +194,7 @@
- **ci**: ko pipeline permissions (#23)
## [v0.2.0-beta.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0-beta.1)
### Features
- update version references in any files (#14)
@ -20,13 +204,14 @@
- **ci**: building release image fails (#21)
## [v0.2.0-beta.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0-beta.0)
### Features
- update version references in any files (#14)
## [v0.1.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.1.0)
### This is the first release ever, so it also includes a lot of other functionality.
### This is the first release ever, so it also includes a lot of other functionality.
### Features

View file

@ -1,6 +1,14 @@
# releaser-pleaser
`releaser-pleaser` is a tool designed to automate versioning and changelog management for your projects. Building on the concepts of [`release-please`](https://github.com/googleapis/release-please), it streamlines the release process through GitHub Actions or GitLab CI.
<p align="center">
<code>releaser-pleaser</code> is a tool designed to automate versioning and changelog management for your projects. Building on the concepts of <a href="https://github.com/googleapis/release-please"><code>release-please</code></a>, it streamlines the release process through GitHub Actions or GitLab CI.
</p>
<p align="center">
<a href="https://apricote.github.io/releaser-pleaser" target="_blank"><img src="https://img.shields.io/badge/Documentation-brightgreen?style=flat-square" alt="Badge: Documentation"/></a>
<a href="https://github.com/apricote/releaser-pleaser/releases" target="_blank"><img src="https://img.shields.io/github/v/release/apricote/releaser-pleaser?sort=semver&display_name=release&style=flat-square&color=green" alt="Badge: Stable Release"/></a>
<img src="https://img.shields.io/badge/License-GPL--3.0-green?style=flat-square" alt="Badge: License GPL-3.0"/>
</p>
## Features
@ -14,20 +22,20 @@
`releaser-pleaser` simplifies release management, allowing maintainers to focus on development while ensuring consistent and well-documented releases.
## Status
This project is still under active development. You can not reasonably use it right now and not all features advertised above work. Keep your eyes open for any releases.
## Relation to `release-please`
After using `release-please` for 1.5 years, I've found it to be the best tool for low-effort releases currently available. While I appreciate many of its features, I identified several additional capabilities that would significantly enhance my workflow. Although it might be possible to incorporate these features into `release-please`, I decided to channel my efforts into creating a new tool that specifically addresses my needs.
After using
`release-please` for 1.5 years, I've found it to be the best tool for low-effort releases currently available. While I appreciate many of its features, I identified several additional capabilities that would significantly enhance my workflow. Although it might be possible to incorporate these features into
`release-please`, I decided to channel my efforts into creating a new tool that specifically addresses my needs.
Key differences in `releaser-pleaser` include:
- Support for multiple forges (both GitHub and GitLab)
- Better support for pre-releases
One notable limitation of `release-please` is its deep integration with the GitHub API, making the addition of support for other platforms (like GitLab) a substantial undertaking. `releaser-pleaser` aims to overcome this limitation by design, offering a more versatile solution for automated release management across different platforms and project requirements.
One notable limitation of
`release-please` is its deep integration with the GitHub API, making the addition of support for other platforms (like GitLab) a substantial undertaking.
`releaser-pleaser` aims to overcome this limitation by design, offering a more versatile solution for automated release management across different platforms and project requirements.
## License

View file

@ -5,26 +5,33 @@ branding:
icon: 'package'
color: 'red'
inputs:
# Remember to update docs/reference/github-action.md
branch:
default: main
description: "This branch is used as the target for releases."
token:
description: 'GitHub token for creating and grooming release PRs, defaults to using secrets.GITHUB_TOKEN'
description: 'GitHub token for creating and updating release PRs, defaults to using secrets.GITHUB_TOKEN'
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: ""
outputs: {}
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: ""
# Remember to update docs/reference/github-action.md
outputs: { }
runs:
using: 'docker'
image: ghcr.io/apricote/releaser-pleaser:v0.2.0-beta.2 # x-releaser-pleaser-version
image: docker://ghcr.io/apricote/releaser-pleaser:v0.7.1 # x-releaser-pleaser-version
args:
- run
- --forge=github
- --branch=${{ inputs.branch }}
- --extra-files="${{ inputs.extra-files }}"
- --updaters="${{ inputs.updaters }}"
env:
GITHUB_TOKEN: ${{ inputs.token }}
GITHUB_TOKEN: "${{ inputs.token }}"
GITHUB_USER: "oauth2"

View file

@ -1,58 +0,0 @@
package rp
import (
"bytes"
_ "embed"
"html/template"
"log"
)
const (
ChangelogFile = "CHANGELOG.md"
ChangelogHeader = "# Changelog"
)
var (
changelogTemplate *template.Template
)
//go:embed changelog.md.tpl
var rawChangelogTemplate string
func init() {
var err error
changelogTemplate, err = template.New("changelog").Parse(rawChangelogTemplate)
if err != nil {
log.Fatalf("failed to parse changelog template: %v", err)
}
}
func NewChangelogEntry(commits []AnalyzedCommit, version, link, prefix, suffix string) (string, error) {
features := make([]AnalyzedCommit, 0)
fixes := make([]AnalyzedCommit, 0)
for _, commit := range commits {
switch commit.Type {
case "feat":
features = append(features, commit)
case "fix":
fixes = append(fixes, commit)
}
}
var changelog bytes.Buffer
err := changelogTemplate.Execute(&changelog, map[string]any{
"Features": features,
"Fixes": fixes,
"Version": version,
"VersionLink": link,
"Prefix": prefix,
"Suffix": suffix,
})
if err != nil {
return "", err
}
return changelog.String(), nil
}

View file

@ -1,22 +0,0 @@
## [{{.Version}}]({{.VersionLink}})
{{- if .Prefix }}
{{ .Prefix }}
{{ end -}}
{{- if (gt (len .Features) 0) }}
### Features
{{ range .Features -}}
- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
{{ end -}}
{{- end -}}
{{- if (gt (len .Fixes) 0) }}
### Bug Fixes
{{ range .Fixes -}}
- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
{{ end -}}
{{- end -}}
{{- if .Suffix }}
{{ .Suffix }}
{{ end -}}

View file

@ -1,50 +1,80 @@
package cmd
import (
"context"
"log/slog"
"os"
"os/signal"
"runtime/debug"
"syscall"
"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
}
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "releaser-pleaser",
Short: "A brief description of your application",
Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
cmd.AddCommand(newRunCommand())
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
return cmd
}
func version() string {
vcsrevision := "unknown"
vcsdirty := ""
buildInfo, ok := debug.ReadBuildInfo()
if ok {
for _, setting := range buildInfo.Settings {
switch setting.Key {
case "vcs.revision":
vcsrevision = setting.Value
case "vcs.modified":
if setting.Value == "true" {
vcsdirty = " (dirty)"
}
}
}
}
return vcsrevision + vcsdirty
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() {
err := rootCmd.Execute()
// Behaviour when cancelling jobs:
//
// GitHub Actions: https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run
// 1. SIGINT
// 2. Wait 7500ms
// 3. SIGTERM
// 4. Wait 2500ms
// 5. SIGKILL
//
// GitLab CI/CD: https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/4446
// 1. SIGTERM
// 2. Wait ???
// 3. SIGKILL
//
// We therefore need to listen on SIGINT and SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
go func() {
// 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()
slog.InfoContext(ctx, "Received shutdown signal, stopping...")
stop()
}()
err := NewRootCmd().ExecuteContext(ctx)
if err != nil {
slog.ErrorContext(ctx, err.Error())
os.Exit(1)
}
}
func init() {
logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.releaser-pleaser.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View file

@ -1,84 +1,149 @@
package cmd
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/spf13/cobra"
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"
"github.com/apricote/releaser-pleaser/internal/updater"
"github.com/apricote/releaser-pleaser/internal/versioning"
)
// runCmd represents the run command
var runCmd = &cobra.Command{
Use: "run",
RunE: run,
}
func newRunCommand() *cobra.Command {
var (
flagForge string
flagBranch string
flagOwner string
flagRepo string
flagExtraFiles string
flagUpdaters []string
var (
flagForge string
flagBranch string
flagOwner string
flagRepo string
flagExtraFiles string
)
func init() {
rootCmd.AddCommand(runCmd)
// Here you will define your flags and configuration settings.
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", "", "")
}
func run(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
logger.DebugContext(ctx, "run called",
"forge", flagForge,
"branch", flagBranch,
"owner", flagOwner,
"repo", flagRepo,
flagAPIURL string
flagAPIToken string
flagUsername string
)
var forge rp.Forge
var cmd = &cobra.Command{
Use: "run",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
logger := log.GetLogger(cmd.ErrOrStderr())
forgeOptions := rp.ForgeOptions{
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,
})
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)
}
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 { // nolint:gocritic // Will become a proper switch once gitlab is added
// case "gitlab":
// f = rp.NewGitLab(forgeOptions)
case "github":
logger.DebugContext(ctx, "using forge GitHub")
forge = rp.NewGitHub(logger, &rp.GitHubOptions{
ForgeOptions: forgeOptions,
Owner: flagOwner,
Repo: flagRepo,
})
}
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)
cmd.PersistentFlags().StringVar(&flagAPIURL, "api-url", "", "")
cmd.PersistentFlags().StringVar(&flagAPIToken, "api-token", "", "")
cmd.PersistentFlags().StringVar(&flagUsername, "username", "", "")
releaserPleaser := rp.New(
forge,
logger,
flagBranch,
rp.NewConventionalCommitsParser(),
rp.SemVerNextVersion,
extraFiles,
[]rp.Updater{&rp.GenericUpdater{}},
)
return releaserPleaser.Run(ctx)
return cmd
}
func parseExtraFiles(input string) []string {
// We quote the arg to avoid issues with the expected newlines in the value.
// Need to remove those quotes before parsing the data
input = strings.Trim(input, `"`)
// In some situations we get a "\n" sequence, where we actually expect new lines,
// replace the two characters with an actual new line
input = strings.ReplaceAll(input, `\n`, "\n")
lines := strings.Split(input, "\n")
extraFiles := make([]string, 0, len(lines))
@ -91,3 +156,26 @@ func parseExtraFiles(input string) []string {
return extraFiles
}
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 })
} else {
names = append(names, u)
}
}
// Make sure we only have unique updaters
slices.Sort(names)
names = slices.Compact(names)
return names
}

104
cmd/rp/cmd/run_test.go Normal file
View file

@ -0,0 +1,104 @@
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parseExtraFiles(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{
name: "empty",
input: ``,
want: []string{},
},
{
name: "empty quoted",
input: `""`,
want: []string{},
},
{
name: "single",
input: `foo.txt`,
want: []string{"foo.txt"},
},
{
name: "single quoted",
input: `"foo.txt"`,
want: []string{"foo.txt"},
},
{
name: "multiple",
input: `foo.txt
dir/Chart.yaml`,
want: []string{"foo.txt", "dir/Chart.yaml"},
},
{
name: "multiple quoted",
input: `"foo.txt
dir/Chart.yaml"`,
want: []string{"foo.txt", "dir/Chart.yaml"},
},
{
name: "multiple with broken new lines",
input: `"action.yml\ntemplates/run.yml\n"`,
want: []string{"action.yml", "templates/run.yml"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseExtraFiles(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
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"},
},
{
name: "remove empty entries",
input: []string{""},
want: []string{"changelog", "generic"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseUpdaters(tt.input)
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -2,6 +2,7 @@ package main
import (
"github.com/apricote/releaser-pleaser/cmd/rp/cmd"
_ "github.com/apricote/releaser-pleaser/internal/log"
)
func main() {

2
codecov.yaml Normal file
View file

@ -0,0 +1,2 @@
ignore:
- "test"

View file

@ -1,78 +0,0 @@
package rp
import (
"fmt"
"github.com/leodido/go-conventionalcommits"
"github.com/leodido/go-conventionalcommits/parser"
)
type Commit struct {
Hash string
Message string
PullRequest *PullRequest
}
type PullRequest struct {
ID int
Title string
Description string
}
type AnalyzedCommit struct {
Commit
Type string
Description string
Scope *string
BreakingChange bool
}
type CommitParser interface {
Analyze(commits []Commit) ([]AnalyzedCommit, error)
}
type ConventionalCommitsParser struct {
machine conventionalcommits.Machine
}
func NewConventionalCommitsParser() *ConventionalCommitsParser {
parserMachine := parser.NewMachine(
parser.WithBestEffort(),
parser.WithTypes(conventionalcommits.TypesConventional),
)
return &ConventionalCommitsParser{
machine: parserMachine,
}
}
func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit, error) {
analyzedCommits := make([]AnalyzedCommit, 0, len(commits))
for _, commit := range commits {
msg, err := c.machine.Parse([]byte(commit.Message))
if err != nil {
return nil, fmt.Errorf("failed to parse message of commit %q: %w", commit.Hash, err)
}
conventionalCommit, ok := msg.(*conventionalcommits.ConventionalCommit)
if !ok {
return nil, fmt.Errorf("unable to get ConventionalCommit from parser result: %T", msg)
}
commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy)
if commitVersionBump > conventionalcommits.UnknownVersion {
// We only care about releasable commits
analyzedCommits = append(analyzedCommits, AnalyzedCommit{
Commit: commit,
Type: conventionalCommit.Type,
Description: conventionalCommit.Description,
Scope: conventionalCommit.Scope,
BreakingChange: conventionalCommit.IsBreakingChange(),
})
}
}
return analyzedCommits, nil
}

View file

@ -1,122 +0,0 @@
package rp
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAnalyzeCommits(t *testing.T) {
tests := []struct {
name string
commits []Commit
expectedCommits []AnalyzedCommit
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty commits",
commits: []Commit{},
expectedCommits: []AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "malformed commit message",
commits: []Commit{
{
Message: "aksdjaklsdjka",
},
},
expectedCommits: nil,
wantErr: assert.Error,
},
{
name: "drops unreleasable",
commits: []Commit{
{
Message: "chore: foobar",
},
},
expectedCommits: []AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "highest bump (patch)",
commits: []Commit{
{
Message: "chore: foobar",
},
{
Message: "fix: blabla",
},
},
expectedCommits: []AnalyzedCommit{
{
Commit: Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (minor)",
commits: []Commit{
{
Message: "fix: blabla",
},
{
Message: "feat: foobar",
},
},
expectedCommits: []AnalyzedCommit{
{
Commit: Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: Commit{Message: "feat: foobar"},
Type: "feat",
Description: "foobar",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (major)",
commits: []Commit{
{
Message: "fix: blabla",
},
{
Message: "feat!: foobar",
},
},
expectedCommits: []AnalyzedCommit{
{
Commit: Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: Commit{Message: "feat!: foobar"},
Type: "feat",
Description: "foobar",
BreakingChange: true,
},
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzedCommits, err := NewConventionalCommitsParser().Analyze(tt.commits)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.expectedCommits, analyzedCommits)
})
}
}

1
docs/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
book

32
docs/SUMMARY.md Normal file
View file

@ -0,0 +1,32 @@
# Summary
[Introduction](introduction.md)
# Tutorials
- [Getting started on GitHub](tutorials/github.md)
- [Getting started on GitLab](tutorials/gitlab.md)
# Explanation
- [Release Pull Request](explanation/release-pr.md)
- [Concurrency and Conflicts](explanation/concurrency-conflicts.md)
# Guides
- [Customizing Release Notes](guides/release-notes.md)
- [Pre-releases](guides/pre-releases.md)
- [Workflow Permissions on GitHub](guides/github-workflow-permissions.md)
- [Updating arbitrary files](guides/updating-arbitrary-files.md)
# Reference
- [Glossary](reference/glossary.md)
- [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)
---
[Changelog](changelog.md)

8
docs/book.toml Normal file
View file

@ -0,0 +1,8 @@
[book]
language = "en"
multilingual = false
src = "."
title = "releaser-pleaser"
[output.html]
git-repository-url = "https://github.com/apricote/releaser-pleaser"

3
docs/changelog.md Normal file
View file

@ -0,0 +1,3 @@
# Changelog
{{#include ../CHANGELOG.md:2: }}

View file

@ -0,0 +1,65 @@
# Concurrency and Conflicts
## Why
`releaser-pleaser` works on the "shared global state" that is your project on GitHub/GitLab. Each execution reads from that state and makes changes to it. While `releaser-pleaser` is generally [idempotent](https://en.wikipedia.org/wiki/Idempotence), we still need to consider concurrent executions for two reasons: avoiding conflicts and saving resources.
### Avoiding conflicts
The [Release Pull Request](release-pr.md) is used by `releaser-pleaser` to show the current release. Users may update the PR description to add additional notes into the Changelog.
When `releaser-pleaser` is running while the user modifies the Release Pull Request description, `releaser-pleaser` may overwrite the description afterward based on its outdated local copy of the pull request.
### Saving resources
While `releaser-pleaser` is idempotent, there is no benefit to running it multiple times in parallel. In the best case, `releaser-pleaser` could be stopped as soon as a new "change" that is relevant to it comes in and restarts based on that new state.
## Measures taken
### Concurrency limits in CI environments
Our default configurations for [GitHub Actions](../tutorials/github.md) and [GitLab CI/CD](../tutorials/gitlab.md) try to limit concurrent `releaser-pleaser` jobs to a single one.
#### GitHub Actions
On GitHub Actions, we use a `concurrency.group` to restrict it to a single running job per repository.
GitHub cancels the currently running job and any other pending ones when a new one is started. This makes sure that `releaser-pleaser` always works with the latest state.
Users need to enable this in their workflow (included in our GitHub tutorial):
```yaml
concurrency:
group: releaser-pleaser
cancel-in-progress: true
```
#### GitLab
On GitLab CI/CD, we use a `resource_group: releaser-pleaser` in our GitLab CI/CD component to restrict it to a single running job per repository. This is part of the component YAML, so users do not need to set this manually.
There is no easy way to cancel the running job, so we let it proceed and rely on the other measures to safely handle the data. Users can enable "auto-cancel redundant pipelines" if they want, but should consider the ramifications for the rest of their CI carefully before doing so.
### Graceful shutdown
When GitHub Actions and GitLab CI/CD cancel jobs, they first sent a signal to the running process (`SIGINT` on GitHub and `SIGTERM` on GitLab). We listen for these signals and initiate a shutdown of the process. This helps save resources by shutting down as fast as possible, but in a controlled manner.
### Re-checking PR description for conflict
When `releaser-pleaser` prepares the Release Pull Request, the first step is to check if there is an existing PR already opened. It then reads from this PR to learn if the user modified the release in some way ([Release Notes](../guides/release-notes.md#for-the-release), [Pre-releases](../guides/pre-releases.md)). Based on this, it prepares the commit and the next iteration of the Release Pull Request description. The last step is to update the Release Pull Request description.
Depending on the time since the last release, a lot of API calls are made to learn about these changes; this can take between a few seconds and a few minutes. If the user makes any changes to the Release Pull Request in this time frame, they are not considered for the next iteration of the description. To make sure that we do not lose these changes, `releaser-pleaser` fetches the Release Pull Request description again right before updating it. In case it changed from the start of the process, the attempt is aborted, and the whole process is retried two times.
This does not fully eliminate the potential for data loss, but reduces the time frame from multiple seconds (up to minutes) to a few hundred milliseconds.
## Related Documentation
- **Explanation**
- [Release Pull Request](release-pr.md)
- **Guide**
- [Pre-releases](../guides/pre-releases.md)
- [Customizing Release Notes](../guides/release-notes.md)
- **Tutorial**
- [Getting started on GitHub](../tutorials/github.md)
- [Getting started on GitLab](../tutorials/gitlab.md)

View file

@ -0,0 +1,21 @@
# Release Pull Request
A _release pull request_ is opened by `releaser-pleaser` when it detects that there are _releasable changes_.
The pull request contains an _auto-generated Changelog_ and a _suggested next version_.
Once someone merges this pull request, `releaser-pleaser` will create a matching Git Tag and Release on GitHub/GitLab.
Maintainers can fill various fields in the pull request description and through labels to change the proposed release. Some examples of this are: _Changelog Prefix & Suffix text_ and _requesting a pre-release_ (`alpha`, `beta`, `rc`) version.
The pull request is automatically updated by `releaser-pleaser` every time it runs.
### Example Screenshot
![Screenshot of an example Release Pull Request on GitHub](./release-pr.png)
## Related Documentation
- **Guide**
- [Pre-releases](../guides/pre-releases.md)
- [Release Notes](../guides/release-notes.md)
- **Reference**
- [Pull Request Options](../reference/pr-options.md)

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:86cd4b5e6a24a1f77bfa882ea330c8af7f88967011a7adab7e24c236104cefe5
size 96399

View file

@ -0,0 +1,83 @@
# Workflow Permissions on GitHub
## Default GitHub token permissions
The [GitHub](../tutorials/github.md) tutorial uses the builtin `GITHUB_TOKEN` for the action to get access to the repository. It uses the following permissions on the token:
```yaml
jobs:
releaser-pleaser:
permissions:
# - list commits
# - push commits for the release pull request
# - push new releases & tags
contents: write
# - read pull requests for Changelog
# - read and write release pull request
# - create labels on the repository
pull-requests: write
```
These permissions are sufficient for simple operations. But fail if you want to run another workflow on `push: tag`.
## Workflows on Tag Push
When using the automatic `GITHUB_TOKEN` to create tags, GitHub does not create new workflow runs that are supposed to be created. This is done to prevent the user from "accidentally creating recursive workflow runs". You can read more about this behaviour in the [GitHub Actions docs](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow).
Workflows that have a trigger on pushed tags are often used to build artifacts for the release, like binaries or container images.
```yaml
on:
push:
tags:
- "v*.*.*"
```
To circumvent this restriction, you can create a personal access token and instruct `releaser-pleaser` to use that instead to talk to GitHub.
### 1. Create Personal Access Token
On your account settings, navigate to the [Personal access tokens](https://github.com/settings/tokens?type=beta) section.
You can either use a "Fine-grained" or "Classic" token for this. Fine-grained tokens can be restricted to specific actions and repositories and are more secure because of this. On the other hand they have a mandatory expiration of 1 year maximum. Classic tokens have unrestricted access to your account, but do not expire.
Copy the token for the next step.
#### Fine-grained token
When you create a fine-grained token, restrict the access to the repository where you are using `releaser-pleaser`.
In the **repository permissions** you need to give **read and write** access for **Contents** and **Pull requests**. All other permissions can be set to **No access** (default).
No **account permissions** are required and you can set all to **No access** (default).
### 2. Repository Secret
Next you need to add the personal access token as a repository secret.
Open the repository settings to **Secrets and variables > Actions**:
> `https://github.com/YOUR-NAME/YOUR-REPO/settings/secrets/actions`
Click on **New repository secret** and add the personal access token to a secret named `RELEASER_PLEASER_TOKEN`.
### 3. Update Workflow
Update the workflow file (`.github/workflows/releaser-pleaser.yaml`) to pass the new secret to the `releaser-pleaser` action. You can also remove the permissions of the job, as they are now unused.
```diff
jobs:
releaser-pleaser:
runs-on: ubuntu-latest # The action uses docker containers
- permissions:
- contents: write
- pull-requests: write
steps:
- name: releaser-pleaser
uses: apricote/releaser-pleaser@v0.2.0
+ with:
+ token: ${{ secrets.RELEASER_PLEASER_TOKEN }}
```
The next release created by releaser-pleaser will now create the follow-up workflows as expected.

View file

@ -0,0 +1,38 @@
# Pre-releases
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.
## Creating a pre-release
If you want to create a pre-release, you can set **one** of the following labels on the release pull request:
- `rp-next-version::alpha`
- `rp-next-version::beta`
- `rp-next-version::rc`
This will cause `releaser-pleaser` to run, and it will change the release pull request to a matching version according to the type of pre-release.
## Versioning
For pre-releases, `releaser-pleaser` analyzes the commits made since the **last stable release**. The version bump from this is then applied to the last stable release and the pre-release info is added to the version number. If a previous pre-release of the matching type exists, the "pre-release counter" at the end of the version is increased by one.
An examples:
- The last stable version was `v1.0.0`
- Since then a `feat` commit was merged, this causes a bump of the minor version: `v1.1.0`
- The release pull request has the label `rp-next-version::beta`. This changes the suggested version to `v1.1.0-beta.0`
If there was already a `v1.1.0-beta.0`, then the suggested version would be `v1.1.0-beta.1`.
Changing the pre-release type (for example from `beta` to `rc`), resets the counter. `v1.1.0-beta.1` would be followed by `v1.1.0-rc.0`.
## Stable Release
`releaser-pleaser` ignores pre-releases when looking for releasable commits. This means that right after creating a new pre-release, `releaser-pleaser` again detects releasable commits and opens a new release pull request for the stable version.
## Related Documentation
- **Reference**
- [Pull Request Options](../reference/pr-options.md)

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74f5f39210bdf9c55f7cec64d4b12465b569646efdfbb2e3eb08b32277cd5145
size 3357

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9c28226eaa769033a45ca801f1e0655178faf86e7ddd764f470ae79d72c4b3c2
size 62031

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04ca48b3250862d282dd54e14c08f9273ada0a49d2300364601799c56b1f6d11
size 72105

View file

@ -0,0 +1,81 @@
# Customizing Release Notes
You can customize the generated Release Notes in two ways:
## For a single commit / pull request
### Editing the Release Notes
After merging a non-release pull request, you can still modify how it appears in the Release Notes.
To do this, add a code block named `rp-commits` in the pull request description. When this block is present, `releaser-pleaser` will use its content for generating Release Notes instead of the commit message. If the code block contains multiple lines, each line will be treated as if it came from separate pull requests. This is useful for pull requests that introduce multiple features or fix several bugs.
You can update the description at any time after merging the pull request but before merging the release pull request. `releaser-pleaser` will then re-run and update the suggested Release Notes accordingly.
> ```rp-commits
> feat(api): add movie endpoints
> feat(api): add cinema endpoints
> fix(db): invalid schema for actor model
> ```
Using GitHub as an example, the pull request you are trying to change the Release Notes for should look like this:
![Screenshot of a pull request page on GitHub. Currently editing the description of the pull request and adding the rp-commits snippet from above.](release-notes-rp-commits.png)
In turn, `releaser-pleaser` updates the release pull request like this:
![Screenshot of a release pull request on GitHub. It shows the release notes with the three commits from the rp-commits example.](release-notes-rp-commits-release-pr.png)
### Removing the pull request from the Release Notes
If you add an empty code block, the pull request will be removed from the Release Notes.
> ```rp-commits
> ```
## For the release
It is possible to add custom **prefix** and **suffix** Markdown-formatted text to the Release Notes.
The release pull request description has text fields where maintainers can add the prefix and suffix. To see these fields, toggle the collapsible section in the description:
![Screenshot of the collapsed section](./release-notes-collapsible.png)
When you edit the description, make sure to put your desired content into the code blocks named `rp-prefix` and `rp-suffix`. Only the content of these blocks is considered.
> ~~~~rp-prefix
> ### Prefix
>
> This will be shown as the Prefix.
> ~~~~
>
> ~~~~rp-suffix
> ### Suffix
>
> This will be shown as the Suffix.
> ~~~~
To match the style of the auto-generated release notes, you should start any headings at level 3 (`### Title`).
Once the description was updated `releaser-pleaser` automatically runs again and adds the prefix and suffix to the Release Notes and to the committed Changelog:
```markdown
## v1.1.0
### Prefix
This will be shown as the Prefix.
### Features
- Added cool new thing (#1)
### Suffix
This will be shown as the Suffix.
```
## Related Documentation
- **Reference**
- [Pull Request Options](../reference/pr-options.md)

View file

@ -0,0 +1,67 @@
# Updating arbitrary files
In some situations it makes sense to have the current version committed in files in the repository:
- Documentation examples
- A source-code file that has the version for user agents and introspection
- Reference to a container image tag that is built from the repository
`releaser-pleaser` can automatically update these references in the [Release PR](../explanation/release-pr.md).
## Markers
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:
```go
// version/version.go
package version
const Version = "v1.0.0" // x-releaser-pleaser-version
```
## Extra Files
You need to tell `releaser-pleaser` which files it should update. This happens through the CI-specific configuration.
### 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:
```yaml
jobs:
releaser-pleaser:
steps:
- uses: apricote/releaser-pleaser@v0.4.0
with:
extra-files: |
version.txt
version/version.go
docker-compose.yml
```
### 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:
```yaml
include:
- component: $CI_SERVER_FQDN/apricote/releaser-pleaser/run@v0.4.0
inputs:
extra-files: |
version.txt
version/version.go
docker-compose.yml
```
## Related Documentation
- **Reference**
- [GitHub Action](../reference/github-action.md#inputs)
- [GitLab CI/CD Component](../reference/gitlab-cicd-component.md#inputs)
- [Updaters](../reference/updaters.md#generic-updater)

3
docs/introduction.md Normal file
View file

@ -0,0 +1,3 @@
# Introduction
{{#include ../README.md:2:}}

View file

@ -0,0 +1,28 @@
# GitHub Action
## Reference
The action is available as `apricote/releaser-pleaser` on GitHub.com.
## Versions
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`.
## 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 by the generic updater. | `""` | <pre><code>version/version.go<br>deploy/deployment.yaml</code></pre> |
| `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
The action does not define any outputs.

View file

@ -0,0 +1,28 @@
# GitLab CI/CD Component
## Reference
The CI/CD component is available as `$CI_SERVER_FQDN/apricote/releaser-pleaser/run` on gitlab.com.
It is being distributed through the CI/CD Catalog: [apricote/releaser-pleaser](https://gitlab.com/explore/catalog/apricote/releaser-pleaser).
## Versions
The `apricote/releaser-pleaser` action is released together with `releaser-pleaser` and they share the version number.
The component 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.4.0`.
## Inputs
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 by the generic updater. | `""` | <pre><code>version/version.go<br>deploy/deployment.yaml</code></pre> |
| `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. | `[]` | <pre><code>- validate-foo<br>- prepare-bar</code></pre> |

View file

@ -0,0 +1,61 @@
# Glossary
### 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.
### 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.
### 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.
[^wp-forge]: Quote from [Wikipedia "Forge (software)"](<https://en.wikipedia.org/wiki/Forge_(software)>)
### Markdown
[Markdown](https://en.wikipedia.org/wiki/Markdown) is a lightweight markup language used on many [forges](#forge) as the preferred way to format text.
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 not considered "stable" and are usually not recommended for most users.
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.
Learn more in the [Release Pull Request](../explanation/release-pr.md) explanation.
### Release Notes
Release Notes describe the changes made to the repository since the last release. They are made available in the [Changelog](#changelog), in Git Tags and through the [forge](#forge)-native Releases.
Learn more in the [Release Notes customization](../guides/release-notes.md) guide.
### 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.
### 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.

View file

@ -0,0 +1,75 @@
# Pull Request Options
The proposed releases can by influenced by changing the description and labels of either the release pull request or the normal pull requests created by other developers. This document lists the available options for both types of pull requests.
## Release Pull Request
Created by `releaser-pleaser`.
### Release Type
**Labels**:
- `rp-next-version::alpha`
- `rp-next-version::beta`
- `rp-next-version::rc`
- `rp-next-version::normal`
Adding one of these labels will change the type of the next release to the one indicated in the label. This is used to create [pre-releases](../guides/pre-releases.md).
Adding more than one of these labels is not allowed and the behaviour if multiple labels are added is undefined.
### Release Notes
**Code Blocks**:
- `rp-prefix`
- `rp-suffix`
Any text in code blocks with these languages is being added to the start or end of the Release Notes and Changelog. Learn more in the [Release Notes](../guides/release-notes.md) guide.
**Examples**:
~~~~rp-prefix
#### Awesome new feature!
This text is at the start of the release notes.
~~~~
~~~~rp-suffix
#### Version Compatibility
And this at the end.
~~~~
### Status
**Labels**:
- `rp-release::pending`
- `rp-release::tagged`
These labels are automatically added by `releaser-pleaser` to release pull requests. They are used to track if the corresponding release was already created.
Users should not set these labels themselves.
## Other Pull Requests
Not created by `releaser-pleaser`.
### Release Notes
**Code Blocks**:
- `rp-commits`
If specified, `releaser-pleaser` will consider each line in the code block as a commit message and add all of them to the Release Notes. Learn more in the [Release Notes](../guides/release-notes.md) guide.
The types of commits (`feat`, `fix`, ...) are also considered for the next version.
**Examples**:
```rp-commits
feat(api): add movie endpoints
fix(db): invalid schema for actor model
```

View file

@ -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.

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3958761dd6d324040566d361b832ffaa3aff30edf6ad4a007a1a4e5bd47c1f79
size 55818

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6591829476e418131fe6bbd52aa9886cb49ef6521da4dab5e9d02de6837742d1
size 16430

76
docs/tutorials/github.md Normal file
View file

@ -0,0 +1,76 @@
# Getting started on GitHub
In this tutorial you will learn how to set up `releaser-pleaser` in your GitHub project with GitHub Actions.
## 1. Repository Settings
### 1.1. Squash Merging
`releaser-pleaser` requires you to use `squash` merging. With other merge options it can not reliably find the right pull request for every commit on `main`.
Open your repository settings to page _General_:
> `https://github.com/YOUR-NAME/YOUR-PROJECT/settings`
In the "Pull Requests" section make sure that only "Allow squash merging" is enabled and "Allow merge commits" and "Allow rebase merging" is disabled.
![Screenshot of the required merge settings](./github-settings-pr.png)
### 1.2. Workflow Permissions
`releaser-pleaser` creates [release pull requests](../explanation/release-pr.md) for you. By default, Actions are not allowed to create pull requests, so we need to enable this.
Open your repository settings to page _Actions > General_:
> `https://github.com/YOUR-NAME/YOUR-PROJECT/settings/actions`
In the "Workflow permissions" section make sure that "Allow GitHub Actions to create and approve pull requests" is enabled.
![Screenshot of the required workflow settings](./github-settings-workflow.png)
## 2. GitHub Actions Workflow
Create a new file `.github/workflows/releaser-pleaser.yaml` with this content. Make sure that it is available on the `main` branch.
```yaml
name: releaser-pleaser
on:
push:
branches: [main]
pull_request_target:
types:
- edited
- labeled
- unlabeled
concurrency:
group: releaser-pleaser
cancel-in-progress: true
jobs:
releaser-pleaser:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: releaser-pleaser
uses: apricote/releaser-pleaser@v0.4.0
```
## 3. Release Pull Request
Once this job runs for the first time, you can check the logs to see what it did.
If you have releasable commits since the last tag, `releaser-pleaser` opens a release pull request for the proposed release.
Once you merge this pull request, `releaser-pleaser` automatically creates a Git tag and GitHub Release with the proposed version and changelog.
## Related Documentation
- **Explanation**
- [Release Pull Request](../explanation/release-pr.md)
- **Guide**
- [GitHub Workflow Permissions](../guides/github-workflow-permissions.md)
- **Reference**
- [GitHub Action](../reference/github-action.md)

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31b485bbe031443c4bfa0d39514dc7e5d524925aa877848def93ee40f69a1897
size 146496

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b853625854582a66ab2438f11e6001a88bcb276225abed536ba68617bde324db
size 57583

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0ce9b9826229851e961ef55d91cb9ba91ca9ca4d955a932d9ff6b10d04788c29
size 41048

94
docs/tutorials/gitlab.md Normal file
View file

@ -0,0 +1,94 @@
# Getting started on GitLab
In this tutorial you will learn how to set up `releaser-pleaser` in your GitLab project with GitLab CI.
> In `releaser-pleaser` documentation we mostly use "Pull Request" (GitHub wording) instead of "Merge Request" (GitLab wording). The GitLab-specific pages are an exception and use "Merge Request".
## 1. Project Settings
### 1.1. Merge Requests
`releaser-pleaser` requires _Fast-forward merges_ and _squashing_. With other merge options it can not reliably find the right merge request for every commit on `main`.
Open your project settings to page _Merge Requests_:
> `https://gitlab.com/YOUR-PATH/YOUR-PROJECT/-/settings/merge_requests`
In the "Merge method" section select "Fast-forward merge":
![Screenshot of the required merge method settings](./gitlab-settings-merge-method.png)
In the "Squash commits when merging" section select "Require":
![Screenshot of the required squash settings](./gitlab-settings-squash.png)
## 2. API Access Token
`releaser-pleaser` uses the GitLab API to create the [release merge request](../explanation/release-pr.md) and subsequent releases for you. The default `GITLAB_TOKEN` available in CI jobs does not have enough permissions for this, so we need to create an Access Token and make it available in a CI variable.
## 2.1. Create Project Access Token
Open your project settings to page _Access tokens_:
> `https://gitlab.com/YOUR-PATH/YOUR-PROJECT/-/settings/access_tokens`
Create a token with these settings:
- **Name**: `releaser-pleaser`
- **Role**: `Maintainer`
- **Scopes**: `api`, `read_repository`, `write_repository`
Copy the created token for the next step.
![Screenshot of the access token settings](./gitlab-access-token.png)
## 2.2. Save token in CI variable
Open your project settings to page _CI/CD_:
> `https://gitlab.com/YOUR-PATH/YOUR-PROJECT/-/settings/ci_cd`
In the section "Variables" click on the "Add variable" button to open the form for a new variable. Use these settings to create the new variable:
- **Type**: Variable
- **Visibility**: Masked
- **Flags**: Uncheck "Protect variable" if your `main` branch is not protected
- **Key**: `RELEASER_PLEASER_TOKEN`
- **Value**: The project access token from the previous step
## 3. GitLab CI/CD
`releaser-pleaser` is published as a [GitLab CI/CD Component](https://docs.gitlab.com/ee/ci/components/): https://gitlab.com/explore/catalog/apricote/releaser-pleaser
Create or open your `.gitlab-ci.yml` and add the following include to your configuration:
```yaml
stages: [build]
include:
- component: $CI_SERVER_FQDN/apricote/releaser-pleaser/run@v0.4.0-beta.1
inputs:
token: $RELEASER_PLEASER_TOKEN
```
> You can set the `stage` input if you want to run `releaser-pleaser` during a different stage.
<div class="warning">
If you want to use `releaser-pleaser` on a self-managed GitLab instance, you need to mirror the GitLab.com component to your instance. See the official [GitLab documentation for details](https://docs.gitlab.com/ee/ci/components/#use-a-gitlabcom-component-in-a-self-managed-instance).
</div>
## 4. Release Merge Request
Once the `releaser-pleaser` job runs for the first time, you can check the logs to see what it did.
If you have releasable commits since the last tag, `releaser-pleaser` opens a release merge request for the proposed release.
Once you merge this merge request, `releaser-pleaser` automatically creates a Git tag and GitLab Release with the proposed version and changelog.
## Related Documentation
- **Explanation**
- [Release Pull Request](../explanation/release-pr.md)
- **Reference**
- [GitLab CI/CD Component](../reference/gitlab-cicd-component.md)

659
forge.go
View file

@ -1,659 +0,0 @@
package rp
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"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/v63/github"
)
const (
GitHubPerPageMax = 100
GitHubPRStateOpen = "open"
GitHubPRStateClosed = "closed"
GitHubEnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
GitHubEnvUsername = "GITHUB_USER"
GitHubEnvRepository = "GITHUB_REPOSITORY"
GitHubLabelColor = "dedede"
)
type Forge interface {
RepoURL() string
CloneURL() string
ReleaseURL(version string) string
GitAuth() transport.AuthMethod
// LatestTags returns the last stable tag created on the main branch. If there is a more recent pre-release tag,
// that is also returned. If no tag is found, it returns nil.
LatestTags(context.Context) (Releases, error)
// CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this
// function should return all commits.
CommitsSince(context.Context, *Tag) ([]Commit, error)
// EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they
// are created them.
EnsureLabelsExist(context.Context, []Label) error
// PullRequestForBranch returns the open pull request between the branch and ForgeOptions.BaseBranch. If no open PR
// exists, it returns nil.
PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error)
// CreatePullRequest opens a new pull/merge request for the ReleasePullRequest.
CreatePullRequest(context.Context, *ReleasePullRequest) error
// UpdatePullRequest updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current description and title.
UpdatePullRequest(context.Context, *ReleasePullRequest) error
// SetPullRequestLabels updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current labels.
SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error
// ClosePullRequest closes the pull/merge request identified through the ID of
// the ReleasePullRequest, as it is no longer required.
ClosePullRequest(context.Context, *ReleasePullRequest) error
// PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are
// merged and have the matching label.
PendingReleases(context.Context, Label) ([]*ReleasePullRequest, error)
// CreateRelease creates a release on the Forge, pointing at the commit with the passed in details.
CreateRelease(ctx context.Context, commit Commit, title, changelog string, prerelease, latest bool) error
}
type ForgeOptions struct {
Repository string
BaseBranch string
}
var _ Forge = &GitHub{}
// var _ Forge = &GitLab{}
type GitHub struct {
options *GitHubOptions
client *github.Client
log *slog.Logger
}
func (g *GitHub) RepoURL() string {
return fmt.Sprintf("https://github.com/%s/%s", g.options.Owner, g.options.Repo)
}
func (g *GitHub) CloneURL() string {
return fmt.Sprintf("https://github.com/%s/%s.git", g.options.Owner, g.options.Repo)
}
func (g *GitHub) ReleaseURL(version string) string {
return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", g.options.Owner, g.options.Repo, version)
}
func (g *GitHub) GitAuth() transport.AuthMethod {
return &http.BasicAuth{
Username: g.options.Username,
Password: g.options.APIToken,
}
}
func (g *GitHub) LatestTags(ctx context.Context) (Releases, error) {
g.log.DebugContext(ctx, "listing all tags in github repository")
page := 1
var releases Releases
for {
tags, resp, err := g.client.Repositories.ListTags(
ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{Page: page, PerPage: GitHubPerPageMax},
)
if err != nil {
return Releases{}, err
}
for _, ghTag := range tags {
tag := &Tag{
Hash: ghTag.GetCommit().GetSHA(),
Name: ghTag.GetName(),
}
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
if err != nil {
g.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
}
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return releases, nil
}
func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) {
var repositoryCommits []*github.RepositoryCommit
var err error
if tag != nil {
repositoryCommits, err = g.commitsSinceTag(ctx, tag)
} else {
repositoryCommits, err = g.commitsSinceInit(ctx)
}
if err != nil {
return nil, err
}
var commits = make([]Commit, 0, len(repositoryCommits))
for _, ghCommit := range repositoryCommits {
commit := Commit{
Hash: ghCommit.GetSHA(),
Message: ghCommit.GetCommit().GetMessage(),
}
commit.PullRequest, err = g.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 (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("base", tag.Hash, "head", head)
log.Debug("comparing commits", "base", tag.Hash, "head", head)
page := 1
var repositoryCommits []*github.RepositoryCommit
for {
log.Debug("fetching page", "page", page)
comparison, resp, err := g.client.Repositories.CompareCommits(
ctx, g.options.Owner, g.options.Repo,
tag.Hash, head, &github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return nil, err
}
if repositoryCommits == nil {
// Pre-initialize slice on first request
log.Debug("found commits", "length", comparison.GetTotalCommits())
repositoryCommits = make([]*github.RepositoryCommit, 0, comparison.GetTotalCommits())
}
repositoryCommits = append(repositoryCommits, comparison.Commits...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return repositoryCommits, nil
}
func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("head", head)
log.Debug("listing all commits")
page := 1
var repositoryCommits []*github.RepositoryCommit
for {
log.Debug("fetching page", "page", page)
commits, resp, err := g.client.Repositories.ListCommits(
ctx, g.options.Owner, g.options.Repo,
&github.CommitsListOptions{
SHA: head,
ListOptions: github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
},
})
if err != nil {
return nil, err
}
if repositoryCommits == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request
log.Debug("found commits", "pages", resp.LastPage)
repositoryCommits = make([]*github.RepositoryCommit, 0, resp.LastPage*GitHubPerPageMax)
}
repositoryCommits = append(repositoryCommits, commits...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return repositoryCommits, nil
}
func (g *GitHub) prForCommit(ctx context.Context, commit Commit) (*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.
log := g.log.With("commit.hash", commit.Hash)
page := 1
var associatedPRs []*github.PullRequest
for {
log.Debug("fetching pull requests associated with commit", "page", page)
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(
ctx, g.options.Owner, g.options.Repo,
commit.Hash, &github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return nil, err
}
associatedPRs = append(associatedPRs, prs...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
var pullrequest *github.PullRequest
for _, pr := range associatedPRs {
// We only look for the PR that has this commit set as the "merge commit" => The result of squashing this branch onto main
if pr.GetMergeCommitSHA() == commit.Hash {
pullrequest = pr
break
}
}
if pullrequest == nil {
return nil, nil
}
return gitHubPRToPullRequest(pullrequest), nil
}
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error {
existingLabels := make([]string, 0, len(labels))
page := 1
for {
g.log.Debug("fetching labels on repo", "page", page)
ghLabels, resp, err := g.client.Issues.ListLabels(
ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return err
}
for _, label := range ghLabels {
existingLabels = append(existingLabels, label.GetName())
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
for _, label := range labels {
if !slices.Contains(existingLabels, string(label)) {
g.log.Info("creating label in repository", "label.name", label)
_, _, err := g.client.Issues.CreateLabel(
ctx, g.options.Owner, g.options.Repo,
&github.Label{
Name: Pointer(string(label)),
Color: Pointer(GitHubLabelColor),
},
)
if err != nil {
return err
}
}
}
return nil
}
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*ReleasePullRequest, error) {
page := 1
for {
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) {
return nil, nil
}
}
return nil, err
}
for _, pr := range prs {
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == GitHubPRStateOpen {
return gitHubPRToReleasePullRequest(pr), nil
}
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return nil, nil
}
func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
ghPR, _, err := g.client.PullRequests.Create(
ctx, g.options.Owner, g.options.Repo,
&github.NewPullRequest{
Title: &pr.Title,
Head: &pr.Head,
Base: &g.options.BaseBranch,
Body: &pr.Description,
},
)
if err != nil {
return err
}
// TODO: String ID?
pr.ID = ghPR.GetNumber()
err = g.SetPullRequestLabels(ctx, pr, []Label{}, pr.Labels)
if err != nil {
return err
}
return nil
}
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
Title: &pr.Title,
Body: &pr.Description,
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error {
for _, label := range remove {
_, err := g.client.Issues.RemoveLabelForIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, string(label),
)
if err != nil {
return err
}
}
addString := make([]string, 0, len(add))
for _, label := range add {
addString = append(addString, string(label))
}
_, _, err := g.client.Issues.AddLabelsToIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, addString,
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
State: Pointer(GitHubPRStateClosed),
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*ReleasePullRequest, error) {
page := 1
var prs []*ReleasePullRequest
for {
ghPRs, resp, err := g.client.PullRequests.List(
ctx, g.options.Owner, g.options.Repo,
&github.PullRequestListOptions{
State: GitHubPRStateClosed,
Base: g.options.BaseBranch,
ListOptions: github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
},
})
if err != nil {
return nil, err
}
if prs == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request
g.log.Debug("found pending releases", "pages", resp.LastPage)
prs = make([]*ReleasePullRequest, 0, (resp.LastPage-1)*GitHubPerPageMax)
}
for _, pr := range ghPRs {
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
return l.GetName() == string(pendingLabel)
})
if !pending {
continue
}
// pr.Merged is always nil :(
if pr.MergedAt == nil {
// Closed and not merged
continue
}
prs = append(prs, gitHubPRToReleasePullRequest(pr))
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return prs, nil
}
func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, changelog string, preRelease, latest bool) error {
makeLatest := ""
if latest {
makeLatest = "true"
} else {
makeLatest = "false"
}
_, _, err := g.client.Repositories.CreateRelease(
ctx, g.options.Owner, g.options.Repo,
&github.RepositoryRelease{
TagName: &title,
TargetCommitish: &commit.Hash,
Name: &title,
Body: &changelog,
Prerelease: &preRelease,
MakeLatest: &makeLatest,
},
)
if err != nil {
return err
}
return nil
}
func gitHubPRToPullRequest(pr *github.PullRequest) *PullRequest {
return &PullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
}
}
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest {
labels := make([]Label, 0, len(pr.Labels))
for _, label := range pr.Labels {
labelName := Label(label.GetName())
if slices.Contains(KnownLabels, Label(label.GetName())) {
labels = append(labels, labelName)
}
}
var releaseCommit *Commit
if pr.MergeCommitSHA != nil {
releaseCommit = &Commit{Hash: pr.GetMergeCommitSHA()}
}
return &ReleasePullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
Labels: labels,
Head: pr.GetHead().GetRef(),
ReleaseCommit: releaseCommit,
}
}
func (g *GitHubOptions) autodiscover() {
if apiToken := os.Getenv(GitHubEnvAPIToken); apiToken != "" {
g.APIToken = apiToken
}
// TODO: Check if there is a better solution for cloning/pushing locally
if username := os.Getenv(GitHubEnvUsername); username != "" {
g.Username = username
}
if envRepository := os.Getenv(GitHubEnvRepository); envRepository != "" {
// GITHUB_REPOSITORY=apricote/releaser-pleaser
parts := strings.Split(envRepository, "/")
if len(parts) == 2 {
g.Owner = parts[0]
g.Repo = parts[1]
g.Repository = envRepository
}
}
}
type GitHubOptions struct {
ForgeOptions
Owner string
Repo string
APIToken string
Username string
}
func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub {
options.autodiscover()
client := github.NewClient(nil)
if options.APIToken != "" {
client = client.WithAuthToken(options.APIToken)
}
gh := &GitHub{
options: options,
client: client,
log: log.With("forge", "github"),
}
return gh
}
type GitLab struct {
options ForgeOptions
}
func (g *GitLab) autodiscover() {
// Read settings from GitLab-CI env vars
}
func NewGitLab(options ForgeOptions) *GitLab {
gl := &GitLab{
options: options,
}
gl.autodiscover()
return gl
}
func (g *GitLab) RepoURL() string {
return fmt.Sprintf("https://gitlab.com/%s", g.options.Repository)
}
func Pointer[T any](value T) *T {
return &value
}

52
git.go
View file

@ -1,52 +0,0 @@
package rp
import (
"context"
"fmt"
"os"
"time"
"github.com/go-git/go-git/v5"
"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"
)
const (
GitRemoteName = "origin"
)
type Tag struct {
Hash string
Name string
}
func CloneRepo(ctx context.Context, cloneURL, branch string, auth transport.AuthMethod) (*git.Repository, error) {
dir, err := os.MkdirTemp("", "releaser-pleaser.*")
if err != nil {
return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err)
}
// TODO: Log tmpdir
fmt.Printf("Clone tmpdir: %s\n", dir)
repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
URL: cloneURL,
RemoteName: GitRemoteName,
ReferenceName: plumbing.NewBranchReferenceName(branch),
SingleBranch: false,
Auth: auth,
})
if err != nil {
return nil, fmt.Errorf("failed to clone repository: %w", err)
}
return repo, nil
}
func GitSignature() *object.Signature {
return &object.Signature{
Name: "releaser-pleaser",
Email: "",
When: time.Now(),
}
}

View file

@ -1 +0,0 @@
package rp

51
go.mod
View file

@ -1,42 +1,59 @@
module github.com/apricote/releaser-pleaser
go 1.23.0
go 1.24.0
toolchain go1.25.5
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
github.com/blang/semver/v4 v4.0.0
github.com/go-git/go-billy/v5 v5.5.0
github.com/go-git/go-git/v5 v5.12.0
github.com/google/go-github/v63 v63.0.0
github.com/go-git/go-billy/v5 v5.7.0
github.com/go-git/go-git/v5 v5.16.4
github.com/google/go-github/v74 v74.0.0
github.com/leodido/go-conventionalcommits v0.12.0
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
github.com/yuin/goldmark v1.7.4
github.com/lmittmann/tint v1.1.2
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/teekennedy/goldmark-markdown v0.5.1
github.com/yuin/goldmark v1.7.16
gitlab.com/gitlab-org/api/client-go v0.161.1
)
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.0.0 // indirect
github.com/cloudflare/circl v1.4.0 // indirect
github.com/cyphar/filepath-securejoin v0.3.1 // 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-20210331224755-41bb18bfe9da // 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
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/pjbgf/sha1cd v0.3.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.0 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/time v0.12.0 // indirect
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

171
go.sum
View file

@ -1,49 +1,65 @@
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=
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
github.com/cloudflare/circl v1.4.0 h1:BV7h5MgrktNzytKmWjpOtdYrf0lkkbF8YMlBGPhJQrY=
github.com/cloudflare/circl v1.4.0/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cyphar/filepath-securejoin v0.3.1 h1:1V7cHiaW+C+39wEfpH6XlLBQo3j/PciWFrgfCLS8XrE=
github.com/cyphar/filepath-securejoin v0.3.1/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc=
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
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/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
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=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
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.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE=
github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA=
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/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=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
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=
@ -59,96 +75,89 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-conventionalcommits v0.12.0 h1:pG01rl8Ze+mxnSSVB2wPdGASXyyU25EGwLUc0bWrmKc=
github.com/leodido/go-conventionalcommits v0.12.0/go.mod h1:DW+n8pQb5w/c7Vba7iGOMS3rkbPqykVlnrDykGjlsJM=
github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI=
github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M=
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rhysd/go-fakeio v1.0.0 h1:+TjiKCOs32dONY7DaoVz/VPOdvRkPfBkEyUDIpM8FQY=
github.com/rhysd/go-fakeio v1.0.0/go.mod h1:joYxF906trVwp2JLrE4jlN7A0z6wrz8O6o1UjarbFzE=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/teekennedy/goldmark-markdown v0.5.1 h1:2lIlJ3AcIwaD1wFl4dflJSJFMhRTKEsEj+asVsu6M/0=
github.com/teekennedy/goldmark-markdown v0.5.1/go.mod h1:so260mNSPELuRyynZY18719dRYlD+OSnAovqsyrOMOM=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
gitlab.com/gitlab-org/api/client-go v0.161.1 h1:XX0EtVGL6cGEdNy9xnJ96CSciIzjCwAVsayItHY1YyU=
gitlab.com/gitlab-org/api/client-go v0.161.1/go.mod h1:YqKcnxyV9OPAL5U99mpwBVEgBPz1PK/3qwqq/3h6bao=
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=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
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.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -0,0 +1,72 @@
package changelog
import (
"bytes"
_ "embed"
"log"
"log/slog"
"text/template"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/markdown"
)
var (
changelogTemplate *template.Template
)
//go:embed changelog.md.tpl
var rawChangelogTemplate string
func init() {
var err error
changelogTemplate, err = template.New("changelog").Parse(rawChangelogTemplate)
if err != nil {
log.Fatalf("failed to parse changelog template: %v", err)
}
}
func DefaultTemplate() *template.Template {
return changelogTemplate
}
type Data struct {
Commits map[string][]commitparser.AnalyzedCommit
Version string
VersionLink string
Prefix string
Suffix string
}
func New(commits map[string][]commitparser.AnalyzedCommit, version, versionLink, prefix, suffix string) Data {
return Data{
Commits: commits,
Version: version,
VersionLink: versionLink,
Prefix: prefix,
Suffix: suffix,
}
}
type Formatting struct {
HideVersionTitle bool
}
func Entry(logger *slog.Logger, tpl *template.Template, data Data, formatting Formatting) (string, error) {
var changelog bytes.Buffer
err := tpl.Execute(&changelog, map[string]any{
"Data": data,
"Formatting": formatting,
})
if err != nil {
return "", err
}
formatted, err := markdown.Format(changelog.String())
if err != nil {
logger.Warn("failed to format changelog entry, using unformatted", "error", err)
return changelog.String(), nil
}
return formatted, nil
}

View file

@ -0,0 +1,24 @@
{{define "entry" -}}
- {{ if .BreakingChange}}**BREAKING**: {{end}}{{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
{{ end }}
{{- if not .Formatting.HideVersionTitle }}
## [{{.Data.Version}}]({{.Data.VersionLink}})
{{ end -}}
{{- if .Data.Prefix }}
{{ .Data.Prefix }}
{{ end -}}
{{- with .Data.Commits.feat }}
### Features
{{ range . -}}{{template "entry" .}}{{end}}
{{- end -}}
{{- with .Data.Commits.fix }}
### Bug Fixes
{{ range . -}}{{template "entry" .}}{{end}}
{{- end -}}
{{- if .Data.Suffix }}
{{ .Data.Suffix }}
{{ end }}

View file

@ -1,9 +1,14 @@
package rp
package changelog
import (
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/testdata"
)
func ptr[T any](input T) *T {
@ -12,7 +17,7 @@ func ptr[T any](input T) *T {
func Test_NewChangelogEntry(t *testing.T) {
type args struct {
analyzedCommits []AnalyzedCommit
analyzedCommits []commitparser.AnalyzedCommit
version string
link string
prefix string
@ -27,19 +32,19 @@ func Test_NewChangelogEntry(t *testing.T) {
{
name: "empty",
args: args{
analyzedCommits: []AnalyzedCommit{},
analyzedCommits: []commitparser.AnalyzedCommit{},
version: "1.0.0",
link: "https://example.com/1.0.0",
},
want: "## [1.0.0](https://example.com/1.0.0)",
want: "## [1.0.0](https://example.com/1.0.0)\n",
wantErr: assert.NoError,
},
{
name: "single feature",
args: args{
analyzedCommits: []AnalyzedCommit{
analyzedCommits: []commitparser.AnalyzedCommit{
{
Commit: Commit{},
Commit: git.Commit{},
Type: "feat",
Description: "Foobar!",
},
@ -47,15 +52,32 @@ func Test_NewChangelogEntry(t *testing.T) {
version: "1.0.0",
link: "https://example.com/1.0.0",
},
want: "## [1.0.0](https://example.com/1.0.0)\n### Features\n\n- Foobar!\n",
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{
analyzedCommits: []AnalyzedCommit{
analyzedCommits: []commitparser.AnalyzedCommit{
{
Commit: Commit{},
Commit: git.Commit{},
Type: "fix",
Description: "Foobar!",
},
@ -63,31 +85,31 @@ func Test_NewChangelogEntry(t *testing.T) {
version: "1.0.0",
link: "https://example.com/1.0.0",
},
want: "## [1.0.0](https://example.com/1.0.0)\n### Bug Fixes\n\n- Foobar!\n",
want: "## [1.0.0](https://example.com/1.0.0)\n\n### Bug Fixes\n\n- Foobar!\n",
wantErr: assert.NoError,
},
{
name: "multiple commits with scopes",
args: args{
analyzedCommits: []AnalyzedCommit{
analyzedCommits: []commitparser.AnalyzedCommit{
{
Commit: Commit{},
Commit: git.Commit{},
Type: "feat",
Description: "Blabla!",
},
{
Commit: Commit{},
Commit: git.Commit{},
Type: "feat",
Description: "So awesome!",
Scope: ptr("awesome"),
},
{
Commit: Commit{},
Commit: git.Commit{},
Type: "fix",
Description: "Foobar!",
},
{
Commit: Commit{},
Commit: git.Commit{},
Type: "fix",
Description: "So sad!",
Scope: ptr("sad"),
@ -97,6 +119,7 @@ func Test_NewChangelogEntry(t *testing.T) {
link: "https://example.com/1.0.0",
},
want: `## [1.0.0](https://example.com/1.0.0)
### Features
- Blabla!
@ -112,56 +135,43 @@ func Test_NewChangelogEntry(t *testing.T) {
{
name: "prefix",
args: args{
analyzedCommits: []AnalyzedCommit{
analyzedCommits: []commitparser.AnalyzedCommit{
{
Commit: Commit{},
Commit: git.Commit{},
Type: "fix",
Description: "Foobar!",
},
},
version: "1.0.0",
link: "https://example.com/1.0.0",
prefix: "### Breaking Changes",
prefix: testdata.MustReadFileString(t, "prefix.txt"),
},
want: `## [1.0.0](https://example.com/1.0.0)
### Breaking Changes
### Bug Fixes
- Foobar!
`,
want: testdata.MustReadFileString(t, "changelog-entry-prefix.txt"),
wantErr: assert.NoError,
},
{
name: "suffix",
args: args{
analyzedCommits: []AnalyzedCommit{
analyzedCommits: []commitparser.AnalyzedCommit{
{
Commit: Commit{},
Commit: git.Commit{},
Type: "fix",
Description: "Foobar!",
},
},
version: "1.0.0",
link: "https://example.com/1.0.0",
suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.",
suffix: testdata.MustReadFileString(t, "suffix.txt"),
},
want: `## [1.0.0](https://example.com/1.0.0)
### Bug Fixes
- Foobar!
### Compatibility
This version is compatible with flux-compensator v2.2 - v2.9.
`,
want: testdata.MustReadFileString(t, "changelog-entry-suffix.txt"),
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := NewChangelogEntry(tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix)
data := New(commitparser.ByType(tt.args.analyzedCommits), tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix)
got, err := Entry(slog.Default(), DefaultTemplate(), data, Formatting{})
if !tt.wantErr(t, err) {
return
}

View file

@ -0,0 +1,32 @@
package commitparser
import (
"github.com/apricote/releaser-pleaser/internal/git"
)
type CommitParser interface {
Analyze(commits []git.Commit) ([]AnalyzedCommit, error)
}
type AnalyzedCommit struct {
git.Commit
Type string
Description string
Scope *string
BreakingChange bool
}
// ByType groups the Commits by the type field. Used by the Changelog.
func ByType(in []AnalyzedCommit) map[string][]AnalyzedCommit {
out := map[string][]AnalyzedCommit{}
for _, commit := range in {
if out[commit.Type] == nil {
out[commit.Type] = make([]AnalyzedCommit, 0, 1)
}
out[commit.Type] = append(out[commit.Type], commit)
}
return out
}

View file

@ -0,0 +1,72 @@
package conventionalcommits
import (
"fmt"
"log/slog"
"strings"
"github.com/leodido/go-conventionalcommits"
"github.com/leodido/go-conventionalcommits/parser"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
)
type Parser struct {
machine conventionalcommits.Machine
logger *slog.Logger
}
func NewParser(logger *slog.Logger) *Parser {
parserMachine := parser.NewMachine(
parser.WithBestEffort(),
parser.WithTypes(conventionalcommits.TypesConventional),
)
return &Parser{
machine: parserMachine,
logger: logger,
}
}
func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, error) {
analyzedCommits := make([]commitparser.AnalyzedCommit, 0, len(commits))
for _, commit := range commits {
msg, err := c.machine.Parse([]byte(strings.TrimSpace(commit.Message)))
if err != nil {
if msg == nil {
c.logger.Warn("failed to parse message of commit, skipping", "commit.hash", commit.Hash, "err", err)
continue
}
c.logger.Warn("failed to parse message of commit fully, trying to use as much as possible", "commit.hash", commit.Hash, "err", err)
}
conventionalCommit, ok := msg.(*conventionalcommits.ConventionalCommit)
if !ok {
return nil, fmt.Errorf("unable to get ConventionalCommit from parser result: %T", msg)
}
if conventionalCommit.Type == "" {
// Parsing broke before getting the type, can not use the commit
c.logger.Warn("commit type was not parsed, skipping", "commit.hash", commit.Hash, "err", err)
continue
}
commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy)
if commitVersionBump > conventionalcommits.UnknownVersion {
// We only care about releasable commits
analyzedCommits = append(analyzedCommits, commitparser.AnalyzedCommit{
Commit: commit,
Type: conventionalCommit.Type,
Description: conventionalCommit.Description,
Scope: conventionalCommit.Scope,
BreakingChange: conventionalCommit.IsBreakingChange(),
})
}
}
return analyzedCommits, nil
}

View file

@ -0,0 +1,157 @@
package conventionalcommits
import (
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
)
func TestAnalyzeCommits(t *testing.T) {
tests := []struct {
name string
commits []git.Commit
expectedCommits []commitparser.AnalyzedCommit
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty commits",
commits: []git.Commit{},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "skips malformed commit message",
commits: []git.Commit{
{
Message: "aksdjaklsdjka",
},
},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
// GitLab seems to create commits with pattern "scope: message\n" if no body is added.
// This has previously caused a parser error "missing a blank line".
// We added a workaround with `strings.TrimSpace()` and this test make sure that it does not break again.
name: "handles title with new line",
commits: []git.Commit{
{
Message: "aksdjaklsdjka",
},
},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "drops unreleasable",
commits: []git.Commit{
{
Message: "chore: foobar",
},
},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "highest bump (patch)",
commits: []git.Commit{
{
Message: "chore: foobar",
},
{
Message: "fix: blabla",
},
},
expectedCommits: []commitparser.AnalyzedCommit{
{
Commit: git.Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (minor)",
commits: []git.Commit{
{
Message: "fix: blabla",
},
{
Message: "feat: foobar",
},
},
expectedCommits: []commitparser.AnalyzedCommit{
{
Commit: git.Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: git.Commit{Message: "feat: foobar"},
Type: "feat",
Description: "foobar",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (major)",
commits: []git.Commit{
{
Message: "fix: blabla",
},
{
Message: "feat!: foobar",
},
},
expectedCommits: []commitparser.AnalyzedCommit{
{
Commit: git.Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: git.Commit{Message: "feat!: foobar"},
Type: "feat",
Description: "foobar",
BreakingChange: true,
},
},
wantErr: assert.NoError,
},
{
name: "success with body",
commits: []git.Commit{
{
Message: "feat: some thing (hz/fl!144)\n\nFixes #15\n\nDepends on !143",
},
},
expectedCommits: []commitparser.AnalyzedCommit{
{
Commit: git.Commit{Message: "feat: some thing (hz/fl!144)\n\nFixes #15\n\nDepends on !143"},
Type: "feat",
Description: "some thing (hz/fl!144)",
BreakingChange: false,
},
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzedCommits, err := NewParser(slog.Default()).Analyze(tt.commits)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.expectedCommits, analyzedCommits)
})
}
}

65
internal/forge/forge.go Normal file
View file

@ -0,0 +1,65 @@
package forge
import (
"context"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/releasepr"
)
type Forge interface {
RepoURL() string
CloneURL() string
ReleaseURL(version string) string
PullRequestURL(id int) string
GitAuth() transport.AuthMethod
// CommitAuthor returns the git author used for the release commit. It should be the user whose token is used to talk to the API.
CommitAuthor(context.Context) (git.Author, error)
// LatestTags returns the last stable tag created on the main branch. If there is a more recent pre-release tag,
// that is also returned. If no tag is found, it returns nil.
LatestTags(context.Context) (git.Releases, error)
// CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this
// function should return all commits.
CommitsSince(context.Context, *git.Tag) ([]git.Commit, error)
// EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they
// are created them.
EnsureLabelsExist(context.Context, []releasepr.Label) error
// PullRequestForBranch returns the open pull request between the branch and Options.BaseBranch. If no open PR
// exists, it returns nil.
PullRequestForBranch(context.Context, string) (*releasepr.ReleasePullRequest, error)
// CreatePullRequest opens a new pull/merge request for the ReleasePullRequest.
CreatePullRequest(context.Context, *releasepr.ReleasePullRequest) error
// UpdatePullRequest updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current description and title.
UpdatePullRequest(context.Context, *releasepr.ReleasePullRequest) error
// SetPullRequestLabels updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current labels.
SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error
// ClosePullRequest closes the pull/merge request identified through the ID of
// the ReleasePullRequest, as it is no longer required.
ClosePullRequest(context.Context, *releasepr.ReleasePullRequest) error
// PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are
// merged and have the matching label.
PendingReleases(context.Context, releasepr.Label) ([]*releasepr.ReleasePullRequest, error)
// CreateRelease creates a release on the Forge, pointing at the commit with the passed in details.
CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, prerelease, latest bool) error
}
type Options struct {
Repository string
BaseBranch string
}

View file

@ -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
}

View file

@ -0,0 +1,544 @@
package github
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"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/v74/github"
"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 (
PerPageMax = 100
PRStateOpen = "open"
PRStateClosed = "closed"
EnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
EnvUsername = "GITHUB_USER"
EnvRepository = "GITHUB_REPOSITORY"
)
var (
gitHubActionsBotAuthor = git.Author{
Name: "github-actions[bot]",
Email: "41898282+github-actions[bot]@users.noreply.github.com",
}
)
var _ forge.Forge = &GitHub{}
type GitHub struct {
options *Options
client *github.Client
log *slog.Logger
}
func (g *GitHub) RepoURL() string {
return fmt.Sprintf("https://github.com/%s/%s", g.options.Owner, g.options.Repo)
}
func (g *GitHub) CloneURL() string {
return fmt.Sprintf("https://github.com/%s/%s.git", g.options.Owner, g.options.Repo)
}
func (g *GitHub) ReleaseURL(version string) string {
return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", g.options.Owner, g.options.Repo, version)
}
func (g *GitHub) PullRequestURL(id int) string {
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", g.options.Owner, g.options.Repo, id)
}
func (g *GitHub) GitAuth() transport.AuthMethod {
return &http.BasicAuth{
Username: g.options.Username,
Password: g.options.APIToken,
}
}
func (g *GitHub) CommitAuthor(ctx context.Context) (git.Author, error) {
g.log.DebugContext(ctx, "getting commit author from current token user")
user, _, err := g.client.Users.Get(ctx, "")
if err != nil {
g.log.WarnContext(ctx, "failed to get commit author from API, using default github-actions[bot] user", "error", err)
return gitHubActionsBotAuthor, nil
}
return git.Author{
Name: user.GetName(),
Email: user.GetEmail(),
}, nil
}
func (g *GitHub) LatestTags(ctx context.Context) (git.Releases, error) {
g.log.DebugContext(ctx, "listing all tags in github repository")
tags, err := all(func(listOptions github.ListOptions) ([]*github.RepositoryTag, *github.Response, error) {
return g.client.Repositories.ListTags(
ctx, g.options.Owner, g.options.Repo,
&listOptions,
)
})
if err != nil {
return git.Releases{}, err
}
var releases git.Releases
for _, ghTag := range tags {
tag := &git.Tag{
Hash: ghTag.GetCommit().GetSHA(),
Name: ghTag.GetName(),
}
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
if err != nil {
g.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 (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
var repositoryCommits []*github.RepositoryCommit
var err error
if tag != nil {
repositoryCommits, err = g.commitsSinceTag(ctx, tag)
} else {
repositoryCommits, err = g.commitsSinceInit(ctx)
}
if err != nil {
return nil, err
}
var commits = make([]git.Commit, 0, len(repositoryCommits))
for _, ghCommit := range repositoryCommits {
commit := git.Commit{
Hash: ghCommit.GetSHA(),
Message: ghCommit.GetCommit().GetMessage(),
}
commit.PullRequest, err = g.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 (g *GitHub) commitsSinceTag(ctx context.Context, tag *git.Tag) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("base", tag.Hash, "head", head)
log.Debug("comparing commits")
repositoryCommits, err := all(
func(listOptions github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
comparison, resp, err := g.client.Repositories.CompareCommits(
ctx, g.options.Owner, g.options.Repo,
tag.Hash, head, &listOptions)
if err != nil {
return nil, nil, err
}
return comparison.Commits, resp, err
})
if err != nil {
return nil, err
}
return repositoryCommits, nil
}
func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("head", head)
log.Debug("listing all commits")
repositoryCommits, err := all(
func(listOptions github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
return g.client.Repositories.ListCommits(
ctx, g.options.Owner, g.options.Repo,
&github.CommitsListOptions{
SHA: head,
ListOptions: listOptions,
})
})
if err != nil {
return nil, err
}
return repositoryCommits, nil
}
func (g *GitHub) prForCommit(ctx 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.
g.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash)
associatedPRs, err := all(
func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
return g.client.PullRequests.ListPullRequestsWithCommit(
ctx, g.options.Owner, g.options.Repo,
commit.Hash, &listOptions)
})
if err != nil {
return nil, err
}
var pullRequest *github.PullRequest
for _, pr := range associatedPRs {
// We only look for the PR that has this commit set as the "merge commit" => The result of squashing this branch onto main
if pr.GetMergeCommitSHA() == commit.Hash {
pullRequest = pr
break
}
}
if pullRequest == nil {
return nil, nil
}
return gitHubPRToPullRequest(pullRequest), nil
}
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
g.log.Debug("fetching labels on repo")
ghLabels, err := all(func(listOptions github.ListOptions) ([]*github.Label, *github.Response, error) {
return g.client.Issues.ListLabels(
ctx, g.options.Owner, g.options.Repo,
&listOptions)
})
if err != nil {
return err
}
for _, label := range labels {
if !slices.ContainsFunc(ghLabels, func(ghLabel *github.Label) bool {
return ghLabel.GetName() == label.Name
}) {
g.log.Info("creating label in repository", "label.name", label.Name)
_, _, err = g.client.Issues.CreateLabel(
ctx, g.options.Owner, g.options.Repo,
&github.Label{
Name: pointer.Pointer(label.Name),
Color: pointer.Pointer(label.Color),
Description: pointer.Pointer(label.Description),
},
)
if err != nil {
return err
}
}
}
return nil
}
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
prs, err := all(
func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
return g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &listOptions)
})
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) {
return nil, nil
}
}
return nil, err
}
for _, pr := range prs {
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == PRStateOpen {
return gitHubPRToReleasePullRequest(pr), nil
}
}
return nil, nil
}
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{
Title: &pr.Title,
Head: &pr.Head,
Base: &g.options.BaseBranch,
Body: &pr.Description,
},
)
if err != nil {
return err
}
pr.ID = ghPR.GetNumber()
err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels)
if err != nil {
return err
}
return nil
}
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
Title: &pr.Title,
Body: &pr.Description,
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
for _, label := range remove {
_, err := g.client.Issues.RemoveLabelForIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, label.Name,
)
if err != nil {
return err
}
}
addString := make([]string, 0, len(add))
for _, label := range add {
addString = append(addString, label.Name)
}
_, _, err := g.client.Issues.AddLabelsToIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, addString,
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
State: pointer.Pointer(PRStateClosed),
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
ghPRs, err := all(func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
return g.client.PullRequests.List(
ctx, g.options.Owner, g.options.Repo,
&github.PullRequestListOptions{
State: PRStateClosed,
Base: g.options.BaseBranch,
ListOptions: listOptions,
})
})
if err != nil {
return nil, err
}
prs := make([]*releasepr.ReleasePullRequest, 0, len(ghPRs))
for _, pr := range ghPRs {
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
return l.GetName() == pendingLabel.Name
})
if !pending {
continue
}
// pr.Merged is always nil :(
if pr.MergedAt == nil {
// Closed and not merged
continue
}
prs = append(prs, gitHubPRToReleasePullRequest(pr))
}
return prs, nil
}
func (g *GitHub) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error {
makeLatest := ""
if latest {
makeLatest = "true"
} else {
makeLatest = "false"
}
_, _, err := g.client.Repositories.CreateRelease(
ctx, g.options.Owner, g.options.Repo,
&github.RepositoryRelease{
TagName: &title,
TargetCommitish: &commit.Hash,
Name: &title,
Body: &changelog,
Prerelease: &preRelease,
MakeLatest: &makeLatest,
},
)
if err != nil {
return err
}
return nil
}
func all[T any](f func(listOptions github.ListOptions) ([]T, *github.Response, error)) ([]T, error) {
results := make([]T, 0)
page := 1
for {
pageResults, resp, err := f(github.ListOptions{Page: page, PerPage: PerPageMax})
if err != nil {
return nil, err
}
results = append(results, pageResults...)
if page == resp.LastPage || resp.LastPage == 0 {
return results, nil
}
page = resp.NextPage
}
}
func gitHubPRToPullRequest(pr *github.PullRequest) *git.PullRequest {
return &git.PullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
}
}
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *releasepr.ReleasePullRequest {
labels := make([]releasepr.Label, 0, len(pr.Labels))
for _, label := range pr.Labels {
labelName := label.GetName()
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.MergeCommitSHA != nil {
releaseCommit = &git.Commit{Hash: pr.GetMergeCommitSHA()}
}
return &releasepr.ReleasePullRequest{
PullRequest: *gitHubPRToPullRequest(pr),
Labels: labels,
Head: pr.GetHead().GetRef(),
ReleaseCommit: releaseCommit,
}
}
func (g *Options) autodiscover() {
if apiToken := os.Getenv(EnvAPIToken); apiToken != "" {
g.APIToken = apiToken
}
// TODO: Check if there is a better solution for cloning/pushing locally
if username := os.Getenv(EnvUsername); username != "" {
g.Username = username
}
if envRepository := os.Getenv(EnvRepository); envRepository != "" {
// GITHUB_REPOSITORY=apricote/releaser-pleaser
parts := strings.Split(envRepository, "/")
if len(parts) == 2 {
g.Owner = parts[0]
g.Repo = parts[1]
g.Repository = envRepository
}
}
}
type Options struct {
forge.Options
Owner string
Repo string
APIToken string
Username string
}
func New(log *slog.Logger, options *Options) *GitHub {
options.autodiscover()
client := github.NewClient(nil)
if options.APIToken != "" {
client = client.WithAuthToken(options.APIToken)
}
gh := &GitHub{
options: options,
client: client,
log: log.With("forge", "github"),
}
return gh
}

View file

@ -0,0 +1,483 @@
package gitlab
import (
"context"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"github.com/blang/semver/v4"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
gitlab "gitlab.com/gitlab-org/api/client-go"
"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 (
PerPageMax = 100
PRStateOpen = "opened"
PRStateMerged = "merged"
PRStateEventClose = "close"
EnvAPIToken = "GITLAB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
// The following vars are from https://docs.gitlab.com/ee/ci/variables/predefined_variables.html
EnvAPIURL = "CI_API_V4_URL"
EnvProjectURL = "CI_PROJECT_URL"
EnvProjectPath = "CI_PROJECT_PATH"
)
type GitLab struct {
options *Options
client *gitlab.Client
log *slog.Logger
}
func (g *GitLab) RepoURL() string {
if g.options.ProjectURL != "" {
return g.options.ProjectURL
}
return fmt.Sprintf("https://gitlab.com/%s", g.options.Path)
}
func (g *GitLab) CloneURL() string {
return fmt.Sprintf("%s.git", g.RepoURL())
}
func (g *GitLab) ReleaseURL(version string) string {
return fmt.Sprintf("%s/-/releases/%s", g.RepoURL(), version)
}
func (g *GitLab) PullRequestURL(id int) string {
return fmt.Sprintf("%s/-/merge_requests/%d", g.RepoURL(), id)
}
func (g *GitLab) GitAuth() transport.AuthMethod {
return &http.BasicAuth{
// Username just needs to be any non-blank value
Username: "api-token",
Password: g.options.APIToken,
}
}
func (g *GitLab) CommitAuthor(ctx context.Context) (git.Author, error) {
g.log.DebugContext(ctx, "getting commit author from current token user")
user, _, err := g.client.Users.CurrentUser(gitlab.WithContext(ctx))
if err != nil {
return git.Author{}, err
}
// TODO: Return bot when nothing is returned?
return git.Author{
Name: user.Name,
Email: user.Email,
}, nil
}
func (g *GitLab) LatestTags(ctx context.Context) (git.Releases, error) {
g.log.DebugContext(ctx, "listing all tags in gitlab repository")
tags, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Tag, *gitlab.Response, error) {
return g.client.Tags.ListTags(g.options.Path, &gitlab.ListTagsOptions{
OrderBy: pointer.Pointer("updated"),
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return git.Releases{}, err
}
var releases git.Releases
for _, glTag := range tags {
tag := &git.Tag{
Hash: glTag.Commit.ID,
Name: glTag.Name,
}
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
if err != nil {
g.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 (g *GitLab) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
var err error
head := g.options.BaseBranch
log := g.log.With("head", head)
refName := ""
if tag != nil {
log = log.With("base", tag.Hash)
refName = fmt.Sprintf("%s..%s", tag.Hash, head)
} else {
refName = head
}
log.Debug("listing commits", "ref.name", refName)
gitLabCommits, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Commit, *gitlab.Response, error) {
return g.client.Commits.ListCommits(g.options.Path, &gitlab.ListCommitsOptions{
RefName: &refName,
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return nil, err
}
var commits = make([]git.Commit, 0, len(gitLabCommits))
for _, ghCommit := range gitLabCommits {
commit := git.Commit{
Hash: ghCommit.ID,
Message: ghCommit.Message,
}
commit.PullRequest, err = g.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 (g *GitLab) prForCommit(ctx context.Context, commit git.Commit) (*git.PullRequest, error) {
// We naively look up the associated MR for each commit through the "List merge requests associated with a commit"
// endpoint. This requires len(commits) requests.
// Using the "List merge requests" endpoint might be faster, as it allows us to fetch 100 arbitrary MRs per request,
// but worst case we need to look up all MRs made in the repository ever.
log := g.log.With("commit.hash", commit.Hash)
log.Debug("fetching pull requests associated with commit")
associatedMRs, _, err := g.client.Commits.ListMergeRequestsByCommit(
g.options.Path, commit.Hash,
gitlab.WithContext(ctx),
)
if err != nil {
return nil, err
}
var mergeRequest *gitlab.BasicMergeRequest
for _, mr := range associatedMRs {
// We only look for the MR that has this commit set as the "merge/squash commit" => The result of squashing this branch onto main
if mr.MergeCommitSHA == commit.Hash || mr.SquashCommitSHA == commit.Hash || mr.SHA == commit.Hash {
mergeRequest = mr
break
}
}
if mergeRequest == nil {
return nil, nil
}
return gitlabMRToPullRequest(mergeRequest), nil
}
func (g *GitLab) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
g.log.Debug("fetching labels on repo")
glLabels, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Label, *gitlab.Response, error) {
return g.client.Labels.ListLabels(g.options.Path, &gitlab.ListLabelsOptions{
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return err
}
for _, label := range labels {
if !slices.ContainsFunc(glLabels, func(glLabel *gitlab.Label) bool {
return glLabel.Name == label.Name
}) {
g.log.Info("creating label in repository", "label.name", label)
_, _, err := g.client.Labels.CreateLabel(g.options.Path, &gitlab.CreateLabelOptions{
Name: pointer.Pointer(label.Name),
Color: pointer.Pointer("#" + label.Color),
Description: pointer.Pointer(label.Description),
},
)
if err != nil {
return err
}
}
}
return nil
}
func (g *GitLab) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
// There should only be a single open merge request from branch into g.options.BaseBranch at any given moment.
// We can skip pagination and just return the first result.
mrs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.options.Path, &gitlab.ListProjectMergeRequestsOptions{
State: pointer.Pointer(PRStateOpen),
SourceBranch: pointer.Pointer(branch),
TargetBranch: pointer.Pointer(g.options.BaseBranch),
ListOptions: gitlab.ListOptions{
Page: 1,
PerPage: PerPageMax,
},
}, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
if len(mrs) >= 1 {
return gitlabMRToReleasePullRequest(mrs[0]), nil
}
return nil, nil
}
func (g *GitLab) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
labels := make(gitlab.LabelOptions, 0, len(pr.Labels))
for _, label := range pr.Labels {
labels = append(labels, label.Name)
}
glMR, _, err := g.client.MergeRequests.CreateMergeRequest(g.options.Path, &gitlab.CreateMergeRequestOptions{
Title: &pr.Title,
Description: &pr.Description,
SourceBranch: &pr.Head,
TargetBranch: &g.options.BaseBranch,
Labels: &labels,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
pr.ID = glMR.IID
return nil
}
func (g *GitLab) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.Path, pr.ID, &gitlab.UpdateMergeRequestOptions{
Title: &pr.Title,
Description: &pr.Description,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func (g *GitLab) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
removeLabels := make(gitlab.LabelOptions, 0, len(remove))
for _, label := range remove {
removeLabels = append(removeLabels, label.Name)
}
addLabels := make(gitlab.LabelOptions, 0, len(add))
for _, label := range add {
addLabels = append(addLabels, label.Name)
}
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.Path, pr.ID, &gitlab.UpdateMergeRequestOptions{
RemoveLabels: &removeLabels,
AddLabels: &addLabels,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func (g *GitLab) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.Path, pr.ID, &gitlab.UpdateMergeRequestOptions{
StateEvent: pointer.Pointer(PRStateEventClose),
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func (g *GitLab) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
glMRs, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.BasicMergeRequest, *gitlab.Response, error) {
return g.client.MergeRequests.ListMergeRequests(&gitlab.ListMergeRequestsOptions{
State: pointer.Pointer(PRStateMerged),
Labels: &gitlab.LabelOptions{pendingLabel.Name},
TargetBranch: pointer.Pointer(g.options.BaseBranch),
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return nil, err
}
prs := make([]*releasepr.ReleasePullRequest, 0, len(glMRs))
for _, mr := range glMRs {
prs = append(prs, gitlabMRToReleasePullRequest(mr))
}
return prs, nil
}
func (g *GitLab) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, _, _ bool) error {
_, _, err := g.client.Releases.CreateRelease(g.options.Path, &gitlab.CreateReleaseOptions{
Name: &title,
TagName: &title,
Description: &changelog,
Ref: &commit.Hash,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func all[T any](f func(listOptions gitlab.ListOptions) ([]T, *gitlab.Response, error)) ([]T, error) {
results := make([]T, 0)
page := 1
for {
pageResults, resp, err := f(gitlab.ListOptions{Page: page, PerPage: PerPageMax})
if err != nil {
return nil, err
}
results = append(results, pageResults...)
if page == resp.TotalPages || resp.TotalPages == 0 {
return results, nil
}
page = resp.NextPage
}
}
func gitlabMRToPullRequest(pr *gitlab.BasicMergeRequest) *git.PullRequest {
return &git.PullRequest{
ID: pr.IID,
Title: pr.Title,
Description: pr.Description,
}
}
func gitlabMRToReleasePullRequest(pr *gitlab.BasicMergeRequest) *releasepr.ReleasePullRequest {
labels := make([]releasepr.Label, 0, len(pr.Labels))
for _, labelName := range pr.Labels {
if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool {
return label.Name == labelName
}); i >= 0 {
labels = append(labels, releasepr.KnownLabels[i])
}
}
// Commit SHA is saved in either [MergeCommitSHA], [SquashCommitSHA] or [SHA] depending on which merge method was used.
var releaseCommit *git.Commit
switch {
case pr.MergeCommitSHA != "":
releaseCommit = &git.Commit{Hash: pr.MergeCommitSHA}
case pr.SquashCommitSHA != "":
releaseCommit = &git.Commit{Hash: pr.SquashCommitSHA}
case pr.MergedAt != nil && pr.SHA != "":
releaseCommit = &git.Commit{Hash: pr.SHA}
}
return &releasepr.ReleasePullRequest{
PullRequest: *gitlabMRToPullRequest(pr),
Labels: labels,
Head: pr.SHA,
ReleaseCommit: releaseCommit,
}
}
func (g *Options) autodiscover() {
// Read settings from GitLab-CI env vars
if apiURL := os.Getenv(EnvAPIURL); apiURL != "" {
g.APIURL = apiURL
}
if apiToken := os.Getenv(EnvAPIToken); apiToken != "" {
g.APIToken = apiToken
}
if projectURL := os.Getenv(EnvProjectURL); projectURL != "" {
g.ProjectURL = projectURL
}
if projectPath := os.Getenv(EnvProjectPath); projectPath != "" {
g.Path = projectPath
}
}
func (g *Options) ClientOptions() []gitlab.ClientOptionFunc {
options := []gitlab.ClientOptionFunc{}
if g.APIURL != "" {
options = append(options, gitlab.WithBaseURL(g.APIURL))
}
return options
}
type Options struct {
forge.Options
ProjectURL string
Path string
APIURL string
APIToken string
}
func New(log *slog.Logger, options *Options) (*GitLab, error) {
log = log.With("forge", "gitlab")
options.autodiscover()
client, err := gitlab.NewClient(options.APIToken, options.ClientOptions()...)
if err != nil {
return nil, err
}
gl := &GitLab{
options: options,
client: client,
log: log,
}
return gl, nil
}

292
internal/git/git.go Normal file
View file

@ -0,0 +1,292 @@
package git
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"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"
)
const (
remoteName = "origin"
newFilePermissions = 0o644
)
type Commit struct {
Hash string
Message string
PullRequest *PullRequest
}
type PullRequest struct {
ID int
Title string
Description string
}
type Tag struct {
Hash string
Name string
}
type Releases struct {
Latest *Tag
Stable *Tag
}
type Author struct {
Name string
Email string
}
func (a Author) signature(when time.Time) *object.Signature {
return &object.Signature{
Name: a.Name,
Email: a.Email,
When: when,
}
}
func (a Author) String() string {
return fmt.Sprintf("%s <%s>", a.Name, a.Email)
}
var (
committer = Author{Name: "releaser-pleaser", Email: ""}
)
func CloneRepo(ctx context.Context, logger *slog.Logger, cloneURL, branch string, auth transport.AuthMethod) (*Repository, error) {
dir, err := os.MkdirTemp("", "releaser-pleaser.*")
if err != nil {
return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err)
}
repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
URL: cloneURL,
RemoteName: remoteName,
ReferenceName: plumbing.NewBranchReferenceName(branch),
SingleBranch: false,
Auth: auth,
})
if err != nil {
return nil, fmt.Errorf("failed to clone repository: %w", err)
}
return &Repository{r: repo, logger: logger, auth: auth}, nil
}
type Repository struct {
r *git.Repository
logger *slog.Logger
auth transport.AuthMethod
}
func (r *Repository) DeleteBranch(ctx context.Context, branch string) error {
if b, _ := r.r.Branch(branch); b != nil {
r.logger.DebugContext(ctx, "deleting local branch", "branch.name", branch)
if err := r.r.DeleteBranch(branch); err != nil {
return err
}
}
return nil
}
func (r *Repository) Checkout(_ context.Context, branch string) error {
worktree, err := r.r.Worktree()
if err != nil {
return err
}
if err = worktree.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branch),
Create: true,
}); err != nil {
return fmt.Errorf("failed to check out branch: %w", err)
}
return nil
}
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
}
fileFlags := os.O_RDWR
if create {
fileFlags |= os.O_CREATE
}
file, err := worktree.Filesystem.OpenFile(path, fileFlags, newFilePermissions)
if err != nil {
return err
}
defer file.Close() //nolint:errcheck
content, err := io.ReadAll(file)
if err != nil {
return err
}
updatedContent, err := updateHook(string(content))
if err != nil {
return fmt.Errorf("failed to run update hook on file %s", path)
}
err = file.Truncate(0)
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = file.Seek(0, 0)
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = file.Write([]byte(updatedContent))
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = worktree.Add(path)
if err != nil {
return fmt.Errorf("failed to add updated file to git worktree: %w", err)
}
return nil
}
func (r *Repository) Commit(_ context.Context, message string, author Author) (Commit, error) {
worktree, err := r.r.Worktree()
if err != nil {
return Commit{}, err
}
now := time.Now()
releaseCommitHash, err := worktree.Commit(message, &git.CommitOptions{
Author: author.signature(now),
Committer: committer.signature(now),
})
if err != nil {
return Commit{}, fmt.Errorf("failed to commit changes: %w", err)
}
return Commit{
Hash: releaseCommitHash.String(),
Message: message,
}, nil
}
// HasChangesWithRemote checks if the following two diffs are equal:
//
// - **Local**: remote/main..branch
// - **Remote**: (git merge-base remote/main remote/branch)..remote/branch
//
// This is done to avoid pushing when the only change would be a rebase of remote/branch onto the current remote/main.
func (r *Repository) HasChangesWithRemote(ctx context.Context, mainBranch, prBranch string) (bool, error) {
return r.hasChangesWithRemote(ctx,
plumbing.NewRemoteReferenceName(remoteName, mainBranch),
plumbing.NewBranchReferenceName(prBranch),
plumbing.NewRemoteReferenceName(remoteName, prBranch),
)
}
func (r *Repository) hasChangesWithRemote(ctx context.Context, mainBranchRef, localPRBranchRef, remotePRBranchRef plumbing.ReferenceName) (bool, error) {
commitOnRemoteMain, err := r.commitFromRef(mainBranchRef)
if err != nil {
return false, err
}
commitOnRemotePRBranch, err := r.commitFromRef(remotePRBranchRef)
if err != nil {
if err.Error() == "reference not found" {
// No remote branch means that there are changes
return true, nil
}
return false, err
}
currentRemotePRMergeBase, err := r.mergeBase(commitOnRemoteMain, commitOnRemotePRBranch)
if err != nil {
return false, err
}
if currentRemotePRMergeBase == nil {
// If there is no merge base something weird has happened with the
// remote main branch, and we should definitely push updates.
return false, nil
}
remoteDiff, err := currentRemotePRMergeBase.PatchContext(ctx, commitOnRemotePRBranch)
if err != nil {
return false, err
}
commitOnLocalPRBranch, err := r.commitFromRef(localPRBranchRef)
if err != nil {
return false, err
}
localDiff, err := commitOnRemoteMain.PatchContext(ctx, commitOnLocalPRBranch)
if err != nil {
return false, err
}
return remoteDiff.String() != localDiff.String(), nil
}
func (r *Repository) commitFromRef(refName plumbing.ReferenceName) (*object.Commit, error) {
ref, err := r.r.Reference(refName, false)
if err != nil {
return nil, err
}
commit, err := r.r.CommitObject(ref.Hash())
if err != nil {
return nil, err
}
return commit, nil
}
func (r *Repository) mergeBase(a, b *object.Commit) (*object.Commit, error) {
mergeBases, err := a.MergeBase(b)
if err != nil {
return nil, err
}
if len(mergeBases) == 0 {
return nil, nil
}
// :shrug: We dont really care which commit we pick, at worst we do an unnecessary push.
return mergeBases[0], nil
}
func (r *Repository) ForcePush(ctx context.Context, branch string) error {
pushRefSpec := config.RefSpec(fmt.Sprintf(
"+%s:%s",
plumbing.NewBranchReferenceName(branch),
// This needs to be the local branch name, not the remotes/origin ref
// See https://stackoverflow.com/a/75727620
plumbing.NewBranchReferenceName(branch),
))
r.logger.DebugContext(ctx, "pushing branch", "branch.name", branch, "refspec", pushRefSpec.String())
return r.r.PushContext(ctx, &git.PushOptions{
RemoteName: remoteName,
RefSpecs: []config.RefSpec{pushRefSpec},
Force: true,
Auth: r.auth,
})
}

174
internal/git/git_test.go Normal file
View file

@ -0,0 +1,174 @@
package git
import (
"context"
"reflect"
"strconv"
"testing"
"time"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
)
func TestAuthor_signature(t *testing.T) {
now := time.Now()
tests := []struct {
author Author
want *object.Signature
}{
{author: Author{Name: "foo", Email: "bar@example.com"}, want: &object.Signature{Name: "foo", Email: "bar@example.com", When: now}},
{author: Author{Name: "bar", Email: "foo@example.com"}, want: &object.Signature{Name: "bar", Email: "foo@example.com", When: now}},
}
for i, tt := range tests {
t.Run(strconv.FormatInt(int64(i), 10), func(t *testing.T) {
if got := tt.author.signature(now); !reflect.DeepEqual(got, tt.want) {
t.Errorf("signature() = %v, want %v", got, tt.want)
}
})
}
}
func TestAuthor_String(t *testing.T) {
tests := []struct {
author Author
want string
}{
{author: Author{Name: "foo", Email: "bar@example.com"}, want: "foo <bar@example.com>"},
{author: Author{Name: "bar", Email: "foo@example.com"}, want: "bar <foo@example.com>"},
}
for i, tt := range tests {
t.Run(strconv.FormatInt(int64(i), 10), func(t *testing.T) {
if got := tt.author.String(); !reflect.DeepEqual(got, tt.want) {
t.Errorf("String() = %v, want %v", got, tt.want)
}
})
}
}
const testMainBranch = "main"
const testPRBranch = "releaser-pleaser"
func TestRepository_HasChangesWithRemote(t *testing.T) {
// go-git/v5 has a bug where it tries to delete the repo root dir (".") multiple times if there is no file left in it.
// this happens while switching branches in worktree.go rmFileAndDirsIfEmpty.
// TODO: Fix bug upstream
// For now I just make sure that there is always at least one file left in the dir by adding an empty "README.md" in the test util.
mainBranchRef := plumbing.NewBranchReferenceName(testMainBranch)
localPRBranchRef := plumbing.NewBranchReferenceName(testPRBranch)
remotePRBranchRef := plumbing.NewBranchReferenceName("remote/" + testPRBranch)
tests := []struct {
name string
repo TestRepo
want bool
wantErr assert.ErrorAssertionFunc
}{
{
name: "no remote pr branch",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(mainBranchRef),
AsNewBranch(localPRBranchRef),
WithFile("VERSION", "v1.1.0"),
),
),
want: true,
wantErr: assert.NoError,
},
{
name: "remote pr branch matches local",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(mainBranchRef),
AsNewBranch(remotePRBranchRef),
WithFile("VERSION", "v1.1.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(mainBranchRef),
AsNewBranch(localPRBranchRef),
WithFile("VERSION", "v1.1.0"),
),
),
want: false,
wantErr: assert.NoError,
},
{
name: "remote pr only needs rebase",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(mainBranchRef),
AsNewBranch(remotePRBranchRef),
WithFile("VERSION", "v1.1.0"),
),
WithCommit(
"feat: new feature on remote",
OnBranch(mainBranchRef),
WithFile("feature", "yes"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(mainBranchRef),
AsNewBranch(localPRBranchRef),
WithFile("VERSION", "v1.1.0"),
),
),
want: false,
wantErr: assert.NoError,
},
{
name: "needs update",
repo: WithTestRepo(
WithCommit(
"chore: release v1.0.0",
WithFile("VERSION", "v1.0.0"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(mainBranchRef),
AsNewBranch(remotePRBranchRef),
WithFile("VERSION", "v1.1.0"),
WithFile("CHANGELOG.md", "Foo"),
),
WithCommit(
"chore: release v1.1.0",
OnBranch(mainBranchRef),
AsNewBranch(localPRBranchRef),
WithFile("VERSION", "v1.1.0"),
WithFile("CHANGELOG.md", "FooBar"),
),
),
want: true,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := tt.repo(t)
got, err := repo.hasChangesWithRemote(context.Background(), mainBranchRef, localPRBranchRef, remotePRBranchRef)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}

189
internal/git/util_test.go Normal file
View file

@ -0,0 +1,189 @@
package git
import (
"fmt"
"io"
"log/slog"
"os"
"testing"
"time"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/stretchr/testify/require"
)
var (
author = &object.Signature{
Name: "releaser-pleaser",
When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC),
}
)
type CommitOption func(*commitOptions)
type commitOptions struct {
cleanFiles bool
files []commitFile
tags []string
newRef plumbing.ReferenceName
parentRef plumbing.ReferenceName
}
type commitFile struct {
path string
content string
}
type TestCommit func(*testing.T, *Repository) error
type TestRepo func(*testing.T) *Repository
func WithCommit(message string, options ...CommitOption) TestCommit {
return func(t *testing.T, repo *Repository) error {
t.Helper()
require.NotEmpty(t, message, "commit message is required")
opts := &commitOptions{}
for _, opt := range options {
opt(opts)
}
wt, err := repo.r.Worktree()
require.NoError(t, err)
if opts.parentRef != "" {
checkoutOptions := &git.CheckoutOptions{}
if opts.newRef != "" {
parentRef, err := repo.r.Reference(opts.parentRef, false)
require.NoError(t, err)
checkoutOptions.Create = true
checkoutOptions.Hash = parentRef.Hash()
checkoutOptions.Branch = opts.newRef
} else {
checkoutOptions.Branch = opts.parentRef
}
err = wt.Checkout(checkoutOptions)
require.NoError(t, err)
}
// Yeet all files
if opts.cleanFiles {
files, err := wt.Filesystem.ReadDir(".")
require.NoError(t, err, "failed to get current files")
for _, fileInfo := range files {
err = wt.Filesystem.Remove(fileInfo.Name())
require.NoError(t, err, "failed to remove file %q", fileInfo.Name())
}
}
// Create new files
for _, fileInfo := range opts.files {
file, err := wt.Filesystem.Create(fileInfo.path)
require.NoError(t, err, "failed to create file %q", fileInfo.path)
_, err = file.Write([]byte(fileInfo.content))
_ = file.Close()
require.NoError(t, err, "failed to write content to file %q", fileInfo.path)
_, err = wt.Add(fileInfo.path)
require.NoError(t, err, "failed to stage changes to file %q", fileInfo.path)
}
// Commit
commitHash, err := wt.Commit(message, &git.CommitOptions{
All: true,
AllowEmptyCommits: true,
Author: author,
Committer: author,
})
require.NoError(t, err, "failed to commit")
// Create tags
for _, tagName := range opts.tags {
_, err = repo.r.CreateTag(tagName, commitHash, nil)
require.NoError(t, err, "failed to create tag %q", tagName)
}
return nil
}
}
func WithFile(path, content string) CommitOption {
return func(opts *commitOptions) {
opts.files = append(opts.files, commitFile{path: path, content: content})
}
}
// WithCleanFiles removes all previous files from the repo. Make sure to leave at least one file in the root
// directory when switching branches!
func WithCleanFiles() CommitOption {
return func(opts *commitOptions) {
opts.cleanFiles = true
}
}
func AsNewBranch(ref plumbing.ReferenceName) CommitOption {
return func(opts *commitOptions) {
opts.newRef = ref
}
}
func OnBranch(ref plumbing.ReferenceName) CommitOption {
return func(opts *commitOptions) {
opts.parentRef = ref
}
}
func WithTag(name string) CommitOption {
return func(opts *commitOptions) {
opts.tags = append(opts.tags, name)
}
}
// Can be useful to debug git issues by using it in a terminal
const useOnDiskTestRepository = false
func WithTestRepo(commits ...TestCommit) TestRepo {
return func(t *testing.T) *Repository {
t.Helper()
repo := &Repository{
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
var err error
initOptions := git.InitOptions{DefaultBranch: plumbing.Main}
if useOnDiskTestRepository {
dir, err := os.MkdirTemp(os.TempDir(), "rp-test-repo-")
require.NoError(t, err, "failed to create temp directory")
repo.r, err = git.PlainInitWithOptions(dir, &git.PlainInitOptions{InitOptions: initOptions})
require.NoError(t, err, "failed to create fs repository")
fmt.Printf("using temp directory: %s", dir)
} else {
repo.r, err = git.InitWithOptions(memory.NewStorage(), memfs.New(), initOptions)
require.NoError(t, err, "failed to create in-memory repository")
}
// Make initial commit
err = WithCommit("chore: init", WithFile("README.md", "# git test util"))(t, repo)
require.NoError(t, err, "failed to create init commit")
for i, commit := range commits {
err = commit(t, repo)
require.NoError(t, err, "failed to create commit %d", i)
}
return repo
}
}

23
internal/log/log.go Normal file
View file

@ -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))
}

View file

@ -7,7 +7,8 @@ import (
// A Section struct represents a section of elements.
type Section struct {
gast.BaseBlock
Name string
Name string
Hidden bool
}
// Dump implements Node.Dump.
@ -26,6 +27,10 @@ func (n *Section) Kind() gast.NodeKind {
return KindSection
}
func (n *Section) HideInOutput() {
n.Hidden = true
}
// NewSection returns a new Section node.
func NewSection(name string) *Section {
return &Section{Name: name}

View file

@ -1,11 +1,13 @@
package extensions
import (
"fmt"
"regexp"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
@ -19,8 +21,8 @@ var (
const (
sectionTrigger = "<!--"
SectionStartFormat = "<!-- section-start %s -->"
SectionEndFormat = "<!-- section-end %s -->"
SectionStartFormat = "<!-- section-start %s -->\n"
SectionEndFormat = "\n<!-- section-end %s -->"
)
type sectionParser struct{}
@ -76,6 +78,45 @@ func (s *sectionParser) Trigger() []byte {
return []byte(sectionTrigger)
}
type SectionMarkdownRenderer struct{}
func NewSectionMarkdownRenderer() renderer.NodeRenderer {
return &SectionMarkdownRenderer{}
}
func (s SectionMarkdownRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindSection, s.renderSection)
}
func (s SectionMarkdownRenderer) renderSection(w util.BufWriter, _ []byte, node gast.Node, enter bool) (gast.WalkStatus, error) {
n := node.(*ast.Section)
if n.Hidden {
return gast.WalkContinue, nil
}
if enter {
// Add blank previous line if applicable
if node.PreviousSibling() != nil && node.HasBlankPreviousLines() {
if _, err := w.WriteRune('\n'); err != nil {
return gast.WalkStop, err
}
}
if _, err := fmt.Fprintf(w, SectionStartFormat, n.Name); err != nil {
return gast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := fmt.Fprintf(w, SectionEndFormat, n.Name); err != nil {
return gast.WalkStop, fmt.Errorf(": %w", err)
}
}
// Somehow the goldmark-markdown renderer does not flush this properly on its own
return gast.WalkContinue, w.Flush()
}
type section struct{}
// Section is an extension that allow you to use group content under a shared parent ast node.
@ -85,4 +126,7 @@ func (e *section) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithBlockParsers(
util.Prioritized(NewSectionParser(), 0),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(NewSectionMarkdownRenderer(), 500),
))
}

View file

@ -1,17 +1,122 @@
package markdown
import (
"bytes"
"strings"
markdown "github.com/teekennedy/goldmark-markdown"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/renderer"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions"
"github.com/apricote/releaser-pleaser/internal/markdown/renderer/markdown"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
)
func New() goldmark.Markdown {
return goldmark.New(
goldmark.WithExtensions(extensions.Section),
goldmark.WithRenderer(renderer.NewRenderer(renderer.WithNodeRenderers(util.Prioritized(markdown.NewRenderer(), 1)))),
goldmark.WithParserOptions(parser.WithASTTransformers(
util.Prioritized(&newLineTransformer{}, 1),
)),
goldmark.WithRenderer(markdown.NewRenderer()),
)
}
// Format the Markdown document in a style mimicking Prettier. This is done for compatibility with other tools
// users might have installed in their IDE. This does not guarantee that the output matches Prettier exactly.
func Format(input string) (string, error) {
var buf bytes.Buffer
buf.Grow(len(input))
err := New().Convert([]byte(input), &buf)
if err != nil {
return "", err
}
return buf.String(), nil
}
func GetCodeBlockText(source []byte, language string, output *string, found *bool) gast.Walker {
return func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if n.Kind() != gast.KindFencedCodeBlock {
return gast.WalkContinue, nil
}
codeBlock := n.(*gast.FencedCodeBlock)
if string(codeBlock.Language(source)) != language {
return gast.WalkContinue, nil
}
*output = textFromLines(source, codeBlock)
if found != nil {
*found = true
}
// Stop looking after we find the first result
return gast.WalkStop, nil
}
}
func GetSectionText(source []byte, name string, output *string) gast.Walker {
return func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if n.Kind() != ast.KindSection {
return gast.WalkContinue, nil
}
section := n.(*ast.Section)
if section.Name != name {
return gast.WalkContinue, nil
}
// Do not show section markings in output, we only care about the content
section.HideInOutput()
// Found the right section
outputBuffer := new(bytes.Buffer)
err := New().Renderer().Render(outputBuffer, source, section)
if err != nil {
return gast.WalkStop, err
}
*output = outputBuffer.String()
// Stop looking after we find the first result
return gast.WalkStop, nil
}
}
func textFromLines(source []byte, n gast.Node) string {
content := make([]byte, 0)
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
content = append(content, line.Value(source)...)
}
return strings.TrimSpace(string(content))
}
func WalkAST(source []byte, walkers ...gast.Walker) (err error) {
doc := New().Parser().Parse(text.NewReader(source))
for _, walker := range walkers {
err = gast.Walk(doc, walker)
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,260 @@
package markdown
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yuin/goldmark/ast"
)
func TestFormat(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "heading spacing",
input: "# Foo\n## Bar\n### Baz",
want: "# Foo\n\n## Bar\n\n### Baz\n",
wantErr: assert.NoError,
},
{
name: "no empty lines for list items",
input: "# Foo\n- 1\n- 2\n",
want: "# Foo\n\n- 1\n- 2\n",
wantErr: assert.NoError,
},
{
name: "sections",
input: "# Foo\n<!-- section-start foobar -->\n- 1\n- 2\n<!-- section-end foobar -->\n",
want: "# Foo\n\n<!-- section-start foobar -->\n- 1\n- 2\n\n<!-- section-end foobar -->\n",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Format(tt.input)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestGetCodeBlockText(t *testing.T) {
type args struct {
source []byte
language string
}
tests := []struct {
name string
args args
wantText string
wantFound bool
wantErr assert.ErrorAssertionFunc
}{
{
name: "no code block",
args: args{
source: []byte("# Foo"),
language: "missing",
},
wantText: "",
wantFound: false,
wantErr: assert.NoError,
},
{
name: "code block",
args: args{
source: []byte("```test\nContent\n```"),
language: "test",
},
wantText: "Content",
wantFound: true,
wantErr: assert.NoError,
},
{
name: "code block with other language",
args: args{
source: []byte("```unknown\nContent\n```"),
language: "test",
},
wantText: "",
wantFound: false,
wantErr: assert.NoError,
},
{
name: "multiple code blocks with different languages",
args: args{
source: []byte("```unknown\nContent\n```\n\n```test\n1337\n```"),
language: "test",
},
wantText: "1337",
wantFound: true,
wantErr: assert.NoError,
},
{
name: "multiple code blocks with same language returns first one",
args: args{
source: []byte("```test\nContent\n```\n\n```test\n1337\n```"),
language: "test",
},
wantText: "Content",
wantFound: true,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotText string
var gotFound bool
err := WalkAST(tt.args.source,
GetCodeBlockText(tt.args.source, tt.args.language, &gotText, &gotFound),
)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.wantText, gotText)
assert.Equal(t, tt.wantFound, gotFound)
})
}
}
func TestGetSectionText(t *testing.T) {
type args struct {
source []byte
name string
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no section",
args: args{
source: []byte("# Foo"),
name: "missing",
},
want: "",
wantErr: assert.NoError,
},
{
name: "section",
args: args{
source: []byte("<!-- section-start test -->\nContent\n<!-- section-end test -->"),
name: "test",
},
want: "Content\n",
wantErr: assert.NoError,
},
{
name: "section with other name",
args: args{
source: []byte("<!-- section-start unknown -->\nContent\n<!-- section-end unknown -->"),
name: "test",
},
want: "",
wantErr: assert.NoError,
},
{
name: "multiple sections with different names",
args: args{
source: []byte("<!-- section-start unknown -->\nContent\n<!-- section-end unknown -->\n\n<!-- section-start test -->\n1337\n<!-- section-end test -->"),
name: "test",
},
want: "1337\n",
wantErr: assert.NoError,
},
{
name: "multiple sections with same name returns first one",
args: args{
source: []byte("<!-- section-start test -->\nContent\n<!-- section-end test -->\n\n<!-- section-start test -->\n1337\n<!-- section-end test -->"),
name: "test",
},
want: "Content\n",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
err := WalkAST(tt.args.source,
GetSectionText(tt.args.source, tt.args.name, &got),
)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestWalkAST(t *testing.T) {
type args struct {
source []byte
walkers []ast.Walker
}
tests := []struct {
name string
args args
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty walker",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, nil
},
},
},
wantErr: assert.NoError,
},
{
name: "returns walker error",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, errors.New("test")
},
},
},
wantErr: assert.Error,
},
{
name: "runs all walkers",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, nil
},
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, errors.New("test")
},
},
},
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WalkAST(tt.args.source, tt.args.walkers...)
if !tt.wantErr(t, err) {
return
}
})
}
}

View file

@ -0,0 +1,31 @@
package markdown
import (
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
)
type newLineTransformer struct{}
var _ parser.ASTTransformer = (*newLineTransformer)(nil) // interface compliance
func (t *newLineTransformer) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
// No error can happen as they can only come from the walker function
_ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering || node.Type() != ast.TypeBlock {
return ast.WalkContinue, nil
}
switch node.Kind() {
case ast.KindListItem:
// Do not add empty lines between every list item
break
default:
// Add empty lines between every other block
node.SetBlankPreviousLines(true)
}
return ast.WalkContinue, nil
})
}

View file

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2022 Rolf Lewis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,4 +0,0 @@
This directory is a vendored copy of https://github.com/RolfLewis/goldmark-down/blob/main/markdown.go.
The original repository is set to a `main` package which can not be imported.
It is under the MIT license.

View file

@ -1,836 +0,0 @@
package markdown
import (
"bytes"
"fmt"
"io"
"strconv"
"strings"
"github.com/yuin/goldmark/ast"
exast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
)
type blockState struct {
node ast.Node
fresh bool
}
type listState struct {
marker byte
ordered bool
index int
}
type Renderer struct {
listStack []listState
openBlocks []blockState
prefixStack []string
prefix []byte
atNewline bool
}
// NewRenderer returns a new Renderer with given options.
func NewRenderer() renderer.NodeRenderer {
r := &Renderer{}
return r
}
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// default registrations
// blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderHeading)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindParagraph, r.renderParagraph)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
// inlines
reg.Register(ast.KindAutoLink, r.renderAutoLink)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindEmphasis, r.renderEmphasis)
reg.Register(ast.KindImage, r.renderImage)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindString, r.renderString)
// GFM Extensions
// Tables
reg.Register(exast.KindTable, r.renderTable)
reg.Register(exast.KindTableHeader, r.renderTableHeader)
reg.Register(exast.KindTableRow, r.renderTableRow)
reg.Register(exast.KindTableCell, r.renderTableCell)
// Strikethrough
reg.Register(exast.KindStrikethrough, r.renderStrikethrough)
// Checkbox
reg.Register(exast.KindTaskCheckBox, r.renderTaskCheckBox)
// releaser-pleaser Extensions
// Section
reg.Register(rpexast.KindSection, r.renderSection)
}
func (r *Renderer) write(w io.Writer, buf []byte) (int, error) {
written := 0
for len(buf) > 0 {
if r.atNewline {
if err := r.beginLine(w); err != nil {
return 0, fmt.Errorf(": %w", err)
}
}
atNewline := false
newline := bytes.IndexByte(buf, '\n')
if newline == -1 {
newline = len(buf) - 1
} else {
atNewline = true
}
n, err := w.Write(buf[:newline+1])
written += n
r.atNewline = n > 0 && atNewline && n == newline+1
if len(r.openBlocks) != 0 {
r.openBlocks[len(r.openBlocks)-1].fresh = false
}
if err != nil {
return written, fmt.Errorf(": %w", err)
}
buf = buf[n:]
}
return written, nil
}
func (r *Renderer) beginLine(w io.Writer) error {
if len(r.openBlocks) != 0 {
current := r.openBlocks[len(r.openBlocks)-1]
if current.node.Kind() == ast.KindParagraph && !current.fresh {
return nil
}
}
n, err := w.Write(r.prefix)
if n != 0 {
r.atNewline = r.prefix[len(r.prefix)-1] == '\n'
}
if err != nil {
return fmt.Errorf(": %w", err)
}
return nil
}
func (r *Renderer) writeLines(w util.BufWriter, source []byte, lines *text.Segments) error {
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
if _, err := r.write(w, line.Value(source)); err != nil {
return fmt.Errorf(": %w", err)
}
}
return nil
}
func (r *Renderer) writeByte(w io.Writer, c byte) error {
if _, err := r.write(w, []byte{c}); err != nil {
return fmt.Errorf(": %w", err)
}
return nil
}
// WriteString writes a string to an io.Writer, ensuring that appropriate indentation and prefixes are added at the
// beginning of each line.
func (r *Renderer) writeString(w io.Writer, s string) (int, error) {
n, err := r.write(w, []byte(s))
if err != nil {
return n, fmt.Errorf(": %w", err)
}
return n, nil
}
// PushIndent adds the specified amount of indentation to the current line prefix.
func (r *Renderer) pushIndent(amount int) {
r.pushPrefix(strings.Repeat(" ", amount))
}
// PushPrefix adds the specified string to the current line prefix.
func (r *Renderer) pushPrefix(prefix string) {
r.prefixStack = append(r.prefixStack, prefix)
r.prefix = append(r.prefix, []byte(prefix)...)
}
// PopPrefix removes the last piece added by a call to PushIndent or PushPrefix from the current line prefix.
func (r *Renderer) popPrefix() {
r.prefix = r.prefix[:len(r.prefix)-len(r.prefixStack[len(r.prefixStack)-1])]
r.prefixStack = r.prefixStack[:len(r.prefixStack)-1]
}
// OpenBlock ensures that each block begins on a new line, and that blank lines are inserted before blocks as
// indicated by node.HasPreviousBlankLines.
func (r *Renderer) openBlock(w util.BufWriter, _ []byte, node ast.Node) error {
r.openBlocks = append(r.openBlocks, blockState{
node: node,
fresh: true,
})
hasBlankPreviousLines := node.HasBlankPreviousLines()
// FIXME: standard goldmark table parser doesn't recognize Blank Previous Lines so we'll always add one
if node.Kind() == exast.KindTable {
hasBlankPreviousLines = true
}
// Work around the fact that the first child of a node notices the same set of preceding blank lines as its parent.
if p := node.Parent(); p != nil && p.FirstChild() == node {
if p.Kind() == ast.KindDocument || p.Kind() == ast.KindListItem || p.HasBlankPreviousLines() {
hasBlankPreviousLines = false
}
}
if hasBlankPreviousLines {
if err := r.writeByte(w, '\n'); err != nil {
return fmt.Errorf(": %w", err)
}
}
r.openBlocks[len(r.openBlocks)-1].fresh = true
return nil
}
// CloseBlock marks the current block as closed.
func (r *Renderer) closeBlock(w io.Writer) error {
if !r.atNewline {
if err := r.writeByte(w, '\n'); err != nil {
return fmt.Errorf(": %w", err)
}
}
r.openBlocks = r.openBlocks[:len(r.openBlocks)-1]
return nil
}
// RenderDocument renders an *ast.Document node to the given BufWriter.
func (r *Renderer) renderDocument(_ util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) {
r.listStack, r.prefixStack, r.prefix, r.atNewline = nil, nil, nil, false
return ast.WalkContinue, nil
}
// RenderHeading renders an *ast.Heading node to the given BufWriter.
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, strings.Repeat("#", node.(*ast.Heading).Level)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, ' '); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderBlockquote renders an *ast.Blockquote node to the given BufWriter.
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, "> "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
r.pushPrefix("> ")
} else {
r.popPrefix()
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderCodeBlock renders an *ast.CodeBlock node to the given BufWriter.
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
r.popPrefix()
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// // Each line of a code block needs to be aligned at the same offset, and a code block must start with at least four
// // spaces. To achieve this, we unconditionally add four spaces to the first line of the code block and indent the
// // rest as necessary.
// if _, err := r.writeString(w, " "); err != nil {
// return ast.WalkStop, fmt.Errorf(": %w", err)
// }
r.pushIndent(4)
if err := r.writeLines(w, source, node.Lines()); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderFencedCodeBlock renders an *ast.FencedCodeBlock node to the given BufWriter.
func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
code := node.(*ast.FencedCodeBlock)
// Write the start of the fenced code block.
fence := []byte("```")
if _, err := r.write(w, fence); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
language := code.Language(source)
if _, err := r.write(w, language); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the contents of the fenced code block.
if err := r.writeLines(w, source, node.Lines()); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the end of the fenced code block.
if err := r.beginLine(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.write(w, fence); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderHTMLBlock renders an *ast.HTMLBlock node to the given BufWriter.
func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the contents of the HTML block.
if err := r.writeLines(w, source, node.Lines()); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the closure line, if any.
html := node.(*ast.HTMLBlock)
if html.HasClosure() {
if _, err := r.write(w, html.ClosureLine.Value(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderList renders an *ast.List node to the given BufWriter.
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
list := node.(*ast.List)
r.listStack = append(r.listStack, listState{
marker: list.Marker,
ordered: list.IsOrdered(),
index: list.Start,
})
} else {
r.listStack = r.listStack[:len(r.listStack)-1]
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderListItem renders an *ast.ListItem node to the given BufWriter.
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
markerWidth := 2 // marker + space
state := &r.listStack[len(r.listStack)-1]
if state.ordered {
width, err := r.writeString(w, strconv.FormatInt(int64(state.index), 10))
if err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
state.index++
markerWidth += width // marker, space, and digits
}
if _, err := r.write(w, []byte{state.marker, ' '}); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
r.pushIndent(markerWidth)
} else {
r.popPrefix()
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderParagraph renders an *ast.Paragraph node to the given BufWriter.
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
// A paragraph that follows another paragraph or a blockquote must be preceded by a blank line.
if !node.HasBlankPreviousLines() {
if prev := node.PreviousSibling(); prev != nil && (prev.Kind() == ast.KindParagraph || prev.Kind() == ast.KindBlockquote) {
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderTextBlock renders an *ast.TextBlock node to the given BufWriter.
func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderThematicBreak renders an *ast.ThematicBreak node to the given BufWriter.
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// TODO: this prints an extra no line
if _, err := r.writeString(w, "--------"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderAutoLink renders an *ast.AutoLink node to the given BufWriter.
func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
if err := r.writeByte(w, '<'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.write(w, node.(*ast.AutoLink).Label(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, '>'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) shouldPadCodeSpan(source []byte, node *ast.CodeSpan) bool {
c := node.FirstChild()
if c == nil {
return false
}
segment := c.(*ast.Text).Segment
text := segment.Value(source)
var firstChar byte
if len(text) > 0 {
firstChar = text[0]
}
allWhitespace := true
for {
if util.FirstNonSpacePosition(text) != -1 {
allWhitespace = false
break
}
c = c.NextSibling()
if c == nil {
break
}
segment = c.(*ast.Text).Segment
text = segment.Value(source)
}
if allWhitespace {
return false
}
var lastChar byte
if len(text) > 0 {
lastChar = text[len(text)-1]
}
return firstChar == '`' || firstChar == ' ' || lastChar == '`' || lastChar == ' '
}
// RenderCodeSpan renders an *ast.CodeSpan node to the given BufWriter.
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
code := node.(*ast.CodeSpan)
delimiter := []byte{'`'}
pad := r.shouldPadCodeSpan(source, code)
if _, err := r.write(w, delimiter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if pad {
if err := r.writeByte(w, ' '); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
text := c.(*ast.Text).Segment
if _, err := r.write(w, text.Value(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
if pad {
if err := r.writeByte(w, ' '); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
if _, err := r.write(w, delimiter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkSkipChildren, nil
}
// RenderEmphasis renders an *ast.Emphasis node to the given BufWriter.
func (r *Renderer) renderEmphasis(w util.BufWriter, _ []byte, node ast.Node, _ bool) (ast.WalkStatus, error) {
em := node.(*ast.Emphasis)
if _, err := r.writeString(w, strings.Repeat("*", em.Level)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) escapeLinkDest(dest []byte) []byte {
requiresEscaping := false
for _, c := range dest {
if c <= 32 || c == '(' || c == ')' || c == 127 {
requiresEscaping = true
break
}
}
if !requiresEscaping {
return dest
}
escaped := make([]byte, 0, len(dest)+2)
escaped = append(escaped, '<')
for _, c := range dest {
if c == '<' || c == '>' {
escaped = append(escaped, '\\')
}
escaped = append(escaped, c)
}
escaped = append(escaped, '>')
return escaped
}
func (r *Renderer) linkTitleDelimiter(title []byte) byte {
for i, c := range title {
if c == '"' && (i == 0 || title[i-1] != '\\') {
return '\''
}
}
return '"'
}
func (r *Renderer) renderLinkOrImage(w util.BufWriter, open string, dest, title []byte, enter bool) error {
if enter {
if _, err := r.writeString(w, open); err != nil {
return fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, "]("); err != nil {
return fmt.Errorf(": %w", err)
}
if _, err := r.write(w, r.escapeLinkDest(dest)); err != nil {
return fmt.Errorf(": %w", err)
}
if len(title) != 0 {
delimiter := r.linkTitleDelimiter(title)
if _, err := fmt.Fprintf(w, ` %c%s%c`, delimiter, string(title), delimiter); err != nil {
return fmt.Errorf(": %w", err)
}
}
if err := r.writeByte(w, ')'); err != nil {
return fmt.Errorf(": %w", err)
}
}
return nil
}
// RenderImage renders an *ast.Image node to the given BufWriter.
func (r *Renderer) renderImage(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
img := node.(*ast.Image)
if err := r.renderLinkOrImage(w, "![", img.Destination, img.Title, enter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderLink renders an *ast.Link node to the given BufWriter.
func (r *Renderer) renderLink(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
link := node.(*ast.Link)
if err := r.renderLinkOrImage(w, "[", link.Destination, link.Title, enter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderRawHTML renders an *ast.RawHTML node to the given BufWriter.
func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkSkipChildren, nil
}
raw := node.(*ast.RawHTML)
for i := 0; i < raw.Segments.Len(); i++ {
segment := raw.Segments.At(i)
if _, err := r.write(w, segment.Value(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkSkipChildren, nil
}
// RenderText renders an *ast.Text node to the given BufWriter.
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
text := node.(*ast.Text)
value := text.Segment.Value(source)
if _, err := r.write(w, value); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
switch {
case text.HardLineBreak():
if _, err := r.writeString(w, "\\\n"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
case text.SoftLineBreak():
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderString renders an *ast.String node to the given BufWriter.
func (r *Renderer) renderString(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
str := node.(*ast.String)
if _, err := r.write(w, str.Value); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, "| "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, " |\n|"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
for x := 0; x < node.ChildCount(); x++ { // use as column count
if _, err := r.writeString(w, " --- |"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, "| "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, " |"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableCell(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if node.NextSibling() != nil {
if _, err := r.writeString(w, " | "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderStrikethrough(w util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) {
if _, err := r.writeString(w, "~~"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTaskCheckBox(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
var fill byte = ' '
if task := node.(*exast.TaskCheckBox); task.IsChecked {
fill = 'x'
}
if _, err := r.write(w, []byte{'[', fill, ']', ' '}); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}

View file

@ -1,35 +0,0 @@
package markdown
import (
"fmt"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/util"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions"
rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
)
func (r *Renderer) renderSection(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
n := node.(*rpexast.Section)
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, fmt.Sprintf(extensions.SectionStartFormat, n.Name)+"\n"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, "\n"+fmt.Sprintf(extensions.SectionEndFormat, n.Name)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}

View file

@ -0,0 +1,5 @@
package pointer
func Pointer[T any](value T) *T {
return &value
}

View file

@ -0,0 +1,54 @@
package releasepr
// Label is the string identifier of a pull/merge request label on the forge.
type Label struct {
Color string
Name string
Description string
}
var (
LabelNextVersionTypeNormal = Label{
Color: "EFC15B",
Name: "rp-next-version::normal",
Description: "Request a stable version",
}
LabelNextVersionTypeRC = Label{
Color: "EFC15B",
Name: "rp-next-version::rc",
Description: "Request a pre-release -rc version",
}
LabelNextVersionTypeBeta = Label{
Color: "EFC15B",
Name: "rp-next-version::beta",
Description: "Request a pre-release -beta version",
}
LabelNextVersionTypeAlpha = Label{
Color: "EFC15B",
Name: "rp-next-version::alpha",
Description: "Request a pre-release -alpha version",
}
)
var (
LabelReleasePending = Label{
Color: "DEDEDE",
Name: "rp-release::pending",
Description: "Release for this PR is pending",
}
LabelReleaseTagged = Label{
Color: "0E8A16",
Name: "rp-release::tagged",
Description: "Release for this PR is created",
}
)
var KnownLabels = []Label{
LabelNextVersionTypeNormal,
LabelNextVersionTypeRC,
LabelNextVersionTypeBeta,
LabelNextVersionTypeAlpha,
LabelReleasePending,
LabelReleaseTagged,
}

View file

@ -0,0 +1,161 @@
package releasepr
import (
"bytes"
_ "embed"
"fmt"
"log"
"regexp"
"text/template"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/markdown"
"github.com/apricote/releaser-pleaser/internal/versioning"
)
var (
releasePRTemplate *template.Template
)
//go:embed releasepr.md.tpl
var rawReleasePRTemplate string
func init() {
var err error
releasePRTemplate, err = template.New("releasepr").Parse(rawReleasePRTemplate)
if err != nil {
log.Fatalf("failed to parse release pr template: %v", err)
}
}
type ReleasePullRequest struct {
git.PullRequest
Labels []Label
Head string
ReleaseCommit *git.Commit
}
func NewReleasePullRequest(head, branch, version, changelogEntry string) (*ReleasePullRequest, error) {
rp := &ReleasePullRequest{
Head: head,
Labels: []Label{LabelReleasePending},
}
rp.SetTitle(branch, version)
if err := rp.SetDescription(changelogEntry, ReleaseOverrides{}); err != nil {
return nil, err
}
return rp, nil
}
type ReleaseOverrides struct {
Prefix string
Suffix string
NextVersionType versioning.NextVersionType
}
const (
DescriptionLanguagePrefix = "rp-prefix"
DescriptionLanguageSuffix = "rp-suffix"
)
const (
MarkdownSectionChangelog = "changelog"
)
const (
TitleFormat = "chore(%s): release %s"
)
var (
TitleRegex = regexp.MustCompile("chore(.*): release (.*)")
)
func (pr *ReleasePullRequest) GetOverrides() (ReleaseOverrides, error) {
overrides := ReleaseOverrides{}
overrides = pr.parseVersioningFlags(overrides)
overrides, err := pr.parseDescription(overrides)
if err != nil {
return ReleaseOverrides{}, err
}
return overrides, nil
}
func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) ReleaseOverrides {
for _, label := range pr.Labels {
switch label {
// Versioning
case LabelNextVersionTypeNormal:
overrides.NextVersionType = versioning.NextVersionTypeNormal
case LabelNextVersionTypeRC:
overrides.NextVersionType = versioning.NextVersionTypeRC
case LabelNextVersionTypeBeta:
overrides.NextVersionType = versioning.NextVersionTypeBeta
case LabelNextVersionTypeAlpha:
overrides.NextVersionType = versioning.NextVersionTypeAlpha
case LabelReleasePending, LabelReleaseTagged:
// These labels have no effect on the versioning.
continue
}
}
return overrides
}
func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (ReleaseOverrides, error) {
source := []byte(pr.Description)
err := markdown.WalkAST(source,
markdown.GetCodeBlockText(source, DescriptionLanguagePrefix, &overrides.Prefix, nil),
markdown.GetCodeBlockText(source, DescriptionLanguageSuffix, &overrides.Suffix, nil),
)
if err != nil {
return ReleaseOverrides{}, err
}
return overrides, nil
}
func (pr *ReleasePullRequest) ChangelogText() (string, error) {
source := []byte(pr.Description)
var sectionText string
err := markdown.WalkAST(source, markdown.GetSectionText(source, MarkdownSectionChangelog, &sectionText))
if err != nil {
return "", err
}
return sectionText, nil
}
func (pr *ReleasePullRequest) SetTitle(branch, version string) {
pr.Title = fmt.Sprintf(TitleFormat, branch, version)
}
func (pr *ReleasePullRequest) Version() (string, error) {
matches := TitleRegex.FindStringSubmatch(pr.Title)
if len(matches) != 3 {
return "", fmt.Errorf("title has unexpected format")
}
return matches[2], nil
}
func (pr *ReleasePullRequest) SetDescription(changelogEntry string, overrides ReleaseOverrides) error {
var description bytes.Buffer
err := releasePRTemplate.Execute(&description, map[string]any{
"Changelog": changelogEntry,
"Overrides": overrides,
})
if err != nil {
return err
}
pr.Description = description.String()
return nil
}

View file

@ -15,18 +15,18 @@ If you want to modify the proposed release, add you overrides here. You can lear
This will be added to the start of the release notes.
```rp-prefix
~~~~rp-prefix
{{- if .Overrides.Prefix }}
{{ .Overrides.Prefix }}{{ end }}
```
~~~~
### Suffix / End
This will be added to the end of the release notes.
```rp-suffix
~~~~rp-suffix
{{- if .Overrides.Suffix }}
{{ .Overrides.Suffix }}{{ end }}
```
~~~~
</details>

View file

@ -0,0 +1,191 @@
package releasepr
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/testdata"
"github.com/apricote/releaser-pleaser/internal/versioning"
)
func TestReleasePullRequest_GetOverrides(t *testing.T) {
tests := []struct {
name string
pr ReleasePullRequest
want ReleaseOverrides
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty",
pr: ReleasePullRequest{},
want: ReleaseOverrides{},
wantErr: assert.NoError,
},
{
// TODO: Test for multiple version flags
name: "single version flag",
pr: ReleasePullRequest{
Labels: []Label{LabelNextVersionTypeAlpha},
},
want: ReleaseOverrides{
NextVersionType: versioning.NextVersionTypeAlpha,
},
wantErr: assert.NoError,
},
{
name: "prefix in description",
pr: ReleasePullRequest{
PullRequest: git.PullRequest{
Description: testdata.MustReadFileString(t, "description-prefix.txt"),
},
},
want: ReleaseOverrides{
Prefix: testdata.MustReadFileString(t, "prefix.txt"),
},
wantErr: assert.NoError,
},
{
name: "suffix in description",
pr: ReleasePullRequest{
PullRequest: git.PullRequest{
Description: testdata.MustReadFileString(t, "description-suffix.txt"),
},
},
want: ReleaseOverrides{
Suffix: testdata.MustReadFileString(t, "suffix.txt"),
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.pr.GetOverrides()
if !tt.wantErr(t, err, "GetOverrides()") {
return
}
assert.Equalf(t, tt.want, got, "GetOverrides()")
})
}
}
func TestReleasePullRequest_ChangelogText(t *testing.T) {
tests := []struct {
name string
description string
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no section",
description: "# Foo\n",
want: "",
wantErr: assert.NoError,
},
{
name: "with section",
description: testdata.MustReadFileString(t, "changelog.txt"),
want: testdata.MustReadFileString(t, "changelog-content.txt"),
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pr := &ReleasePullRequest{
PullRequest: git.PullRequest{
Description: tt.description,
},
}
got, err := pr.ChangelogText()
if !tt.wantErr(t, err, "ChangelogText()") {
return
}
assert.Equalf(t, tt.want, got, "ChangelogText()")
})
}
}
func TestReleasePullRequest_SetTitle(t *testing.T) {
type args struct {
branch string
version string
}
tests := []struct {
name string
pr *ReleasePullRequest
args args
want string
}{
{
name: "simple update",
pr: &ReleasePullRequest{
PullRequest: git.PullRequest{
Title: "foo: bar",
},
},
args: args{
branch: "main",
version: "v1.0.0",
},
want: "chore(main): release v1.0.0",
},
{
name: "no previous title",
pr: &ReleasePullRequest{},
args: args{
branch: "release-1.x",
version: "v1.1.1-rc.0",
},
want: "chore(release-1.x): release v1.1.1-rc.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.pr.SetTitle(tt.args.branch, tt.args.version)
assert.Equal(t, tt.want, tt.pr.Title)
})
}
}
func TestReleasePullRequest_SetDescription(t *testing.T) {
tests := []struct {
name string
changelogEntry string
overrides ReleaseOverrides
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no overrides",
changelogEntry: `## v1.0.0`,
overrides: ReleaseOverrides{},
want: testdata.MustReadFileString(t, "description-no-overrides.txt"),
wantErr: assert.NoError,
},
{
name: "existing overrides",
changelogEntry: `## v1.0.0`,
overrides: ReleaseOverrides{
Prefix: testdata.MustReadFileString(t, "prefix.txt"),
Suffix: testdata.MustReadFileString(t, "suffix.txt"),
},
want: testdata.MustReadFileString(t, "description-overrides.txt"),
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pr := &ReleasePullRequest{}
err := pr.SetDescription(tt.changelogEntry, tt.overrides)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, pr.Description)
})
}
}

View file

@ -0,0 +1,7 @@
This is the changelog
## Awesome
### New
#### Changes

View file

@ -0,0 +1,19 @@
## [1.0.0](https://example.com/1.0.0)
## Foo
- Cool thing
```go
// Some code example
func IsPositive(number int) error {
if number < 0 {
return fmt.Errorf("number %d is negative", number)
}
return nil
}
```
### Bug Fixes
- Foobar!

View file

@ -0,0 +1,9 @@
## [1.0.0](https://example.com/1.0.0)
### Bug Fixes
- Foobar!
## Compatibility
This version is compatible with flux-compensator v2.2 - v2.9.

13
internal/testdata/changelog.txt vendored Normal file
View file

@ -0,0 +1,13 @@
# Foobar
<!-- section-start changelog -->
This is the changelog
## Awesome
### New
#### Changes
<!-- section-end changelog -->
Suffix Things

View file

@ -0,0 +1,28 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
## Release Notes
### Prefix / Start
This will be added to the start of the release notes.
~~~~rp-prefix
~~~~
### Suffix / End
This will be added to the end of the release notes.
~~~~rp-suffix
~~~~
</details>

View file

@ -0,0 +1,44 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
## Release Notes
### Prefix / Start
This will be added to the start of the release notes.
~~~~rp-prefix
## Foo
- Cool thing
```go
// Some code example
func IsPositive(number int) error {
if number < 0 {
return fmt.Errorf("number %d is negative", number)
}
return nil
}
```
~~~~
### Suffix / End
This will be added to the end of the release notes.
~~~~rp-suffix
## Compatibility
This version is compatible with flux-compensator v2.2 - v2.9.
~~~~
</details>

View file

@ -0,0 +1,41 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
## Release Notes
### Prefix / Start
This will be added to the start of the release notes.
~~~~rp-prefix
## Foo
- Cool thing
```go
// Some code example
func IsPositive(number int) error {
if number < 0 {
return fmt.Errorf("number %d is negative", number)
}
return nil
}
```
~~~~
### Suffix / End
This will be added to the end of the release notes.
~~~~rp-suffix
~~~~
</details>

View file

@ -0,0 +1,31 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs.
## Release Notes
### Prefix / Start
This will be added to the start of the release notes.
~~~~rp-prefix
~~~~
### Suffix / End
This will be added to the end of the release notes.
~~~~rp-suffix
## Compatibility
This version is compatible with flux-compensator v2.2 - v2.9.
~~~~
</details>

13
internal/testdata/prefix.txt vendored Normal file
View file

@ -0,0 +1,13 @@
## Foo
- Cool thing
```go
// Some code example
func IsPositive(number int) error {
if number < 0 {
return fmt.Errorf("number %d is negative", number)
}
return nil
}
```

3
internal/testdata/suffix.txt vendored Normal file
View file

@ -0,0 +1,3 @@
## Compatibility
This version is compatible with flux-compensator v2.2 - v2.9.

19
internal/testdata/testdata.go vendored Normal file
View file

@ -0,0 +1,19 @@
package testdata
import (
"embed"
"testing"
)
//go:embed *.txt
var testdata embed.FS
func MustReadFileString(t *testing.T, name string) string {
t.Helper()
content, err := testdata.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(content)
}

View file

@ -1,126 +0,0 @@
package testutils
import (
"testing"
"time"
"github.com/go-git/go-billy/v5/memfs"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/go-git/go-git/v5/storage/memory"
"github.com/stretchr/testify/require"
)
var author = &object.Signature{
Name: "releaser-pleaser",
When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC),
}
type CommitOption func(*commitOptions)
type commitOptions struct {
cleanFiles bool
files []commitFile
tags []string
}
type commitFile struct {
path string
content string
}
type Commit func(*testing.T, *git.Repository) error
type Repo func(*testing.T) *git.Repository
func WithCommit(message string, options ...CommitOption) Commit {
return func(t *testing.T, repo *git.Repository) error {
t.Helper()
require.NotEmpty(t, message, "commit message is required")
opts := &commitOptions{}
for _, opt := range options {
opt(opts)
}
wt, err := repo.Worktree()
require.NoError(t, err)
// Yeet all files
if opts.cleanFiles {
files, err := wt.Filesystem.ReadDir(".")
require.NoError(t, err, "failed to get current files")
for _, fileInfo := range files {
err = wt.Filesystem.Remove(fileInfo.Name())
require.NoError(t, err, "failed to remove file %q", fileInfo.Name())
}
}
// Create new files
for _, fileInfo := range opts.files {
file, err := wt.Filesystem.Create(fileInfo.path)
require.NoError(t, err, "failed to create file %q", fileInfo.path)
_, err = file.Write([]byte(fileInfo.content))
file.Close()
require.NoError(t, err, "failed to write content to file %q", fileInfo.path)
}
// Commit
commitHash, err := wt.Commit(message, &git.CommitOptions{
All: true,
AllowEmptyCommits: true,
Author: author,
Committer: author,
})
require.NoError(t, err, "failed to commit")
// Create tags
for _, tagName := range opts.tags {
_, err = repo.CreateTag(tagName, commitHash, nil)
require.NoError(t, err, "failed to create tag %q", tagName)
}
return nil
}
}
func WithFile(path, content string) CommitOption {
return func(opts *commitOptions) {
opts.files = append(opts.files, commitFile{path: path, content: content})
}
}
func WithCleanFiles() CommitOption {
return func(opts *commitOptions) {
opts.cleanFiles = true
}
}
func WithTag(name string) CommitOption {
return func(opts *commitOptions) {
opts.tags = append(opts.tags, name)
}
}
func WithTestRepo(commits ...Commit) Repo {
return func(t *testing.T) *git.Repository {
t.Helper()
repo, err := git.Init(memory.NewStorage(), memfs.New())
require.NoError(t, err, "failed to create in-memory repository")
// Make initial commit
err = WithCommit("chore: init")(t, repo)
require.NoError(t, err, "failed to create init commit")
for i, commit := range commits {
err = commit(t, repo)
require.NoError(t, err, "failed to create commit %d", i)
}
return repo
}
}

View file

@ -0,0 +1,47 @@
package updater
import (
"fmt"
"regexp"
)
const (
ChangelogHeader = "# Changelog"
ChangelogFile = "CHANGELOG.md"
)
var (
ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`)
)
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")
}
if headerIndex != nil {
// Remove the header from the content
content = content[headerIndex[1]:]
}
content = ChangelogHeader + "\n\n" + info.ChangelogEntry + content
return content, nil
}
}

View file

@ -0,0 +1,68 @@
package updater
import (
"testing"
"github.com/stretchr/testify/assert"
)
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: "",
info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"},
want: "# Changelog\n\n## v1.0.0\n",
wantErr: assert.NoError,
},
{
name: "well-formatted changelog",
content: `# Changelog
## v0.0.1
- Bazzle
## v0.1.0
### Bazuuum
`,
info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"},
want: `# Changelog
## v1.0.0
- Version 1, juhu.
## v0.0.1
- Bazzle
## v0.1.0
### Bazuuum
`,
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,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runUpdaterTest(t, Changelog(), tt)
})
}
}

View file

@ -0,0 +1,35 @@
package updater
import (
"regexp"
"strings"
)
var GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`)
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")
return GenericUpdaterSemVerRegex.ReplaceAllString(content, version+"${2}"), nil
}
}

View file

@ -0,0 +1,61 @@
package updater
import (
"testing"
"github.com/stretchr/testify/assert"
)
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",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "v1.2.0 // x-releaser-pleaser-version",
wantErr: assert.NoError,
},
{
name: "multiline line",
content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "Foooo\n\v1.2.0\nv1.2.0 // x-releaser-pleaser-version\n",
wantErr: assert.NoError,
},
{
name: "invalid existing version",
content: "1.0 // x-releaser-pleaser-version",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "1.0 // x-releaser-pleaser-version",
wantErr: assert.NoError,
},
{
name: "complicated line",
content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "version: v1.2.0 => Awesome, isnt it? x-releaser-pleaser-version foobar",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runUpdaterTest(t, Generic([]string{"version.txt"}), tt)
})
}
}

View file

@ -0,0 +1,39 @@
package updater
import (
"regexp"
"strings"
)
// PackageJson creates an updater that modifies the version field in 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")
// 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
return versionRegex.ReplaceAllString(content, `${1}"`+version+`"`), nil
}
}

View file

@ -0,0 +1,62 @@
package updater
import (
"testing"
"github.com/stretchr/testify/assert"
)
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"}`,
info: ReleaseInfo{
Version: "v2.0.5",
},
want: `{"name":"test","version":"2.0.5"}`,
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}",
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`,
info: ReleaseInfo{
Version: "v2.0.0",
},
want: `not json`,
wantErr: assert.NoError,
},
{
name: "json without version",
content: `{"name":"test"}`,
info: ReleaseInfo{
Version: "v2.0.0",
},
want: `{"name":"test"}`,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runUpdaterTest(t, PackageJson(), tt)
})
}
}

Some files were not shown because too many files have changed in this diff Show more