Compare commits

...

260 commits
v0.2.0 ... main

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
108 changed files with 6548 additions and 3252 deletions

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

View file

@ -8,33 +8,30 @@ jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write # To push a branch
pages: write # To push to a GitHub Pages site
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@v4
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
lfs: "true"
- name: Install latest mdbook
run: |
tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name')
url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz"
mkdir mdbook
curl -sSL $url | tar -xz --directory=./mdbook
echo `pwd`/mdbook >> $GITHUB_PATH
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Build Book
run: |
# This assumes your book is in the root of your repository.
# Just add a `cd` here if you need to change to another directory.
cd docs
mdbook build
working-directory: docs
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
# Upload entire repository
path: 'docs/book'
path: "docs/book"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
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,7 +2,7 @@ name: releaser-pleaser
on:
push:
branches: [main]
branches: [ main ]
# Using pull_request_target to avoid tainting the actual release PR with code from open feature pull requests
pull_request_target:
types:
@ -10,7 +10,14 @@ on:
- 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:
@ -18,23 +25,18 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: main
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
# Build container image from current commit and replace image ref in `action.yml`
# Without this, any new flags in `action.yml` would break the job in this repository until the new
# version is released. But a new version can only be released if this job works.
- uses: ko-build/setup-ko@v0.7
- run: ko build --bare --local --tags ci github.com/apricote/releaser-pleaser/cmd/rp
- run: ko build --bare --local --platform linux/amd64 --tags ci github.com/apricote/releaser-pleaser/cmd/rp
- run: mkdir -p .github/actions/releaser-pleaser
- run: "sed -i 's|image: .*$|image: ghcr.io/apricote/releaser-pleaser:ci|g' action.yml"
- 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
@ -43,3 +45,4 @@ jobs:
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,176 @@
# 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)
@ -13,6 +183,7 @@
- **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)
@ -23,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)
@ -32,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

@ -14,19 +14,24 @@ inputs:
required: false
default: ${{ github.token }}
extra-files:
description: 'List of files that are scanned for version references.'
description: 'List of files that are scanned for version references by the generic updater.'
required: false
default: ""
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: {}
outputs: { }
runs:
using: 'docker'
image: ghcr.io/apricote/releaser-pleaser:v0.2.0 # 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 }}
- --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,21 +1,29 @@
package cmd
import (
"fmt"
"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
}
var rootCmd = &cobra.Command{
Use: "releaser-pleaser",
Short: "",
Long: ``,
Version: version(),
cmd.AddCommand(newRunCommand())
return cmd
}
func version() string {
@ -24,7 +32,6 @@ func version() string {
buildInfo, ok := debug.ReadBuildInfo()
if ok {
fmt.Println(buildInfo.String())
for _, setting := range buildInfo.Settings {
switch setting.Key {
case "vcs.revision":
@ -41,15 +48,33 @@ func version() string {
}
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,
}))
}

View file

@ -1,81 +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"
)
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)
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))
@ -88,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)
})
}
}

View file

@ -5,24 +5,27 @@
# Tutorials
- [Getting started on GitHub](tutorials/github.md)
- [Getting started on GitLab]()
- [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]()
- [GitLab CI/CD Component](reference/gitlab-cicd-component.md)
- [Updaters](reference/updaters.md)
---

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,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

@ -4,7 +4,34 @@ You can customize the generated Release Notes in two ways:
## For a single commit / pull request
This feature is still being worked on. Check out [#5](https://github.com/apricote/releaser-pleaser/issues/5) for the current status.
### 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
@ -16,17 +43,17 @@ The release pull request description has text fields where maintainers can add t
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
> ~~~~rp-prefix
> ### Prefix
>
> This will be shown as the Prefix.
> ```
> ~~~~
>
> ```rp-suffix
> ~~~~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`).

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)

View file

@ -8,17 +8,20 @@ The action is available as `apricote/releaser-pleaser` on GitHub.com.
The `apricote/releaser-pleaser` action is released together with `releaser-pleaser` and they share the version number.
The action does not support floating tags (e.g. `v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead: `apricote/releaser-pleaser@v0.2.0`.
The action does not support floating tags (e.g.
`v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead:
`apricote/releaser-pleaser@v0.2.0`.
## Inputs
The following inputs are supported by the `apricote/releaser-pleaser` GitHub Action.
| Input | Description | Default | Example |
| ------------- | :----------------------------------------------------- | --------------: | -------------------------------------------------------------------: |
| `branch` | This branch is used as the target for releases. | `main` | `master` |
| `token` | GitHub token for creating and updating release PRs | `$GITHUB_TOKEN` | `${{secrets.RELEASER_PLEASER_TOKEN}}` |
| `extra-files` | List of files that are scanned for version references. | `""` | <pre><code>version/version.go<br>deploy/deployment.yaml</code></pre> |
| 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

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

@ -2,17 +2,21 @@
### Changelog
The Changelog is a file in the repository (`CHANGELOG.md`) that contains the [Release Notes](#release-notes) for every release of that repository. Usually, new releases are added at the top of the file.
The Changelog is a file in the repository (
`CHANGELOG.md`) that contains the [Release Notes](#release-notes) for every release of that repository. Usually, new releases are added at the top of the file.
### Conventional Commits
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is a specification for commit messages. It is the only supported commit message schema in `releaser-pleaser`. Follow the link to learn more.
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is a specification for commit messages. It is the only supported commit message schema in
`releaser-pleaser`. Follow the link to learn more.
### Forge
A **forge** is a web-based collaborative software platform for both developing and sharing computer applications.[^wp-forge]
A **forge
** is a web-based collaborative software platform for both developing and sharing computer applications.[^wp-forge]
Right now only **GitHub** is supported. We plan to support **GitLab** in the future ([#4](https://github.com/apricote/releaser-pleaser/issues/4)). For other forges like Forgejo or Gitea, please open an issue and submit a pull request.
Right now only **GitHub** is supported. We plan to support **GitLab
** in the future ([#4](https://github.com/apricote/releaser-pleaser/issues/4)). For other forges like Forgejo or Gitea, please open an issue and submit a pull request.
[^wp-forge]: Quote from [Wikipedia "Forge (software)"](<https://en.wikipedia.org/wiki/Forge_(software)>)
@ -24,7 +28,8 @@ In `releaser-pleaser` Markdown is used for most texts.
### Pre-release
Pre-releases are a concept of [SemVer](#semantic-versioning-semver). They follow the normal versioning schema but use a suffix out of `-alpha.X`, `-beta.X` and `-rc.X`.
Pre-releases are a concept of [SemVer](#semantic-versioning-semver). They follow the normal versioning schema but use a suffix out of
`-alpha.X`, `-beta.X` and `-rc.X`.
Pre-releases are not considered "stable" and are usually not recommended for most users.
@ -32,7 +37,9 @@ Learn more in the [Pre-releases](../guides/pre-releases.md) guide.
### Release Pull Request
A Release Pull Request is opened by `releaser-pleaser` whenever it finds releasable commits in your project. It proposes a new version number and the Changelog. Once it is merged, `releaser-pleaser` creates a matching release.
A Release Pull Request is opened by
`releaser-pleaser` whenever it finds releasable commits in your project. It proposes a new version number and the Changelog. Once it is merged,
`releaser-pleaser` creates a matching release.
Learn more in the [Release Pull Request](../explanation/release-pr.md) explanation.
@ -44,4 +51,11 @@ Learn more in the [Release Notes customization](../guides/release-notes.md) guid
### Semantic Versioning (SemVer)
[Semantic Versioning](https://semver.org/) is a specification for version numbers. It is the only supported versioning schema in `releaser-pleaser`. Follow the link to learn more.
[Semantic Versioning](https://semver.org/) is a specification for version numbers. It is the only supported versioning schema in
`releaser-pleaser`. Follow the link to learn more.
### Updater
Updaters can update or create files that will be included in [Release Pull Request](#release-pull-request). Examples of Updaters are
`changelog` for `CHANGELOG.md`, `generic` that can update arbitrary files and
`packagejson` that knows how to update Node.JS `package.json` files.

View file

@ -28,6 +28,20 @@ Adding more than one of these labels is not allowed and the behaviour if multipl
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**:
@ -43,4 +57,19 @@ Users should not set these labels themselves.
Not created by `releaser-pleaser`.
Normal pull requests do not support any options right now.
### 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

@ -1,6 +1,6 @@
# GitHub
# Getting started on GitHub
In this tutorial we show how to install `releaser-pleaser` in your GitHub project.
In this tutorial you will learn how to set up `releaser-pleaser` in your GitHub project with GitHub Actions.
## 1. Repository Settings
@ -44,6 +44,10 @@ on:
- labeled
- unlabeled
concurrency:
group: releaser-pleaser
cancel-in-progress: true
jobs:
releaser-pleaser:
runs-on: ubuntu-latest
@ -52,7 +56,7 @@ jobs:
pull-requests: write
steps:
- name: releaser-pleaser
uses: apricote/releaser-pleaser@v0.2.0
uses: apricote/releaser-pleaser@v0.4.0
```
## 3. Release Pull Request

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

View file

@ -0,0 +1,23 @@
package updater
type ReleaseInfo struct {
Version string
ChangelogEntry string
}
type Updater interface {
Files() []string
CreateNewFiles() bool
Update(info ReleaseInfo) func(content string) (string, error)
}
type NewUpdater func(ReleaseInfo) Updater
func WithInfo(info ReleaseInfo, constructors ...NewUpdater) []Updater {
updaters := make([]Updater, 0, len(constructors))
for _, constructor := range constructors {
updaters = append(updaters, constructor(info))
}
return updaters
}

View file

@ -0,0 +1,26 @@
package updater
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
type updaterTestCase struct {
name string
content string
info ReleaseInfo
want string
wantErr assert.ErrorAssertionFunc
}
func runUpdaterTest(t *testing.T, u Updater, tt updaterTestCase) {
t.Helper()
got, err := u.Update(tt.info)(tt.content)
if !tt.wantErr(t, err, fmt.Sprintf("Updater(%v, %v)", tt.content, tt.info)) {
return
}
assert.Equalf(t, tt.want, got, "Updater(%v, %v)", tt.content, tt.info)
}

View file

@ -1,23 +1,20 @@
package rp
package versioning
import (
"fmt"
"strings"
"github.com/blang/semver/v4"
"github.com/leodido/go-conventionalcommits"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
)
type Releases struct {
Latest *Tag
Stable *Tag
}
var SemVer Strategy = semVer{}
type VersioningStrategy = func(Releases, conventionalcommits.VersionBump, NextVersionType) (string, error)
type semVer struct{}
var _ VersioningStrategy = SemVerNextVersion
func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump, nextVersionType NextVersionType) (string, error) {
func (s semVer) NextVersion(r git.Releases, versionBump VersionBump, nextVersionType NextVersionType) (string, error) {
latest, err := parseSemverWithDefault(r.Latest)
if err != nil {
return "", fmt.Errorf("failed to parse latest version: %w", err)
@ -36,13 +33,13 @@ func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump,
}
switch versionBump {
case conventionalcommits.UnknownVersion:
case UnknownVersion:
return "", fmt.Errorf("invalid latest bump (unknown)")
case conventionalcommits.PatchVersion:
case PatchVersion:
err = next.IncrementPatch()
case conventionalcommits.MinorVersion:
case MinorVersion:
err = next.IncrementMinor()
case conventionalcommits.MajorVersion:
case MajorVersion:
err = next.IncrementMajor()
}
if err != nil {
@ -68,18 +65,18 @@ func SemVerNextVersion(r Releases, versionBump conventionalcommits.VersionBump,
return "v" + next.String(), nil
}
func VersionBumpFromCommits(commits []AnalyzedCommit) conventionalcommits.VersionBump {
bump := conventionalcommits.UnknownVersion
func BumpFromCommits(commits []commitparser.AnalyzedCommit) VersionBump {
bump := UnknownVersion
for _, commit := range commits {
entryBump := conventionalcommits.UnknownVersion
entryBump := UnknownVersion
switch {
case commit.BreakingChange:
entryBump = conventionalcommits.MajorVersion
entryBump = MajorVersion
case commit.Type == "feat":
entryBump = conventionalcommits.MinorVersion
entryBump = MinorVersion
case commit.Type == "fix":
entryBump = conventionalcommits.PatchVersion
entryBump = PatchVersion
}
if entryBump > bump {
@ -97,7 +94,7 @@ func setPRVersion(version *semver.Version, prType string, count uint64) {
}
}
func parseSemverWithDefault(tag *Tag) (semver.Version, error) {
func parseSemverWithDefault(tag *git.Tag) (semver.Version, error) {
version := "v0.0.0"
if tag != nil {
version = tag.Name
@ -113,3 +110,16 @@ func parseSemverWithDefault(tag *Tag) (semver.Version, error) {
return parsedVersion, nil
}
func (s semVer) IsPrerelease(version string) bool {
semVersion, err := parseSemverWithDefault(&git.Tag{Hash: "", Name: version})
if err != nil {
return false
}
if len(semVersion.Pre) > 0 {
return true
}
return false
}

View file

@ -1,17 +1,19 @@
package rp
package versioning
import (
"fmt"
"testing"
"github.com/leodido/go-conventionalcommits"
"github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
)
func TestReleases_NextVersion(t *testing.T) {
func TestSemVer_NextVersion(t *testing.T) {
type args struct {
releases Releases
versionBump conventionalcommits.VersionBump
releases git.Releases
versionBump VersionBump
nextVersionType NextVersionType
}
tests := []struct {
@ -23,11 +25,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "simple bump (major)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion,
versionBump: MajorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v2.0.0",
@ -36,11 +38,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "simple bump (minor)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion,
versionBump: MinorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.2.0",
@ -49,11 +51,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "simple bump (patch)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.1.2",
@ -62,11 +64,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "normal to prerelease (major)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion,
versionBump: MajorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v2.0.0-rc.0",
@ -75,11 +77,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "normal to prerelease (minor)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion,
versionBump: MinorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.2.0-rc.0",
@ -88,11 +90,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "normal to prerelease (patch)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.1.2-rc.0",
@ -101,11 +103,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease bump (major)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v2.0.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v2.0.0-rc.0"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion,
versionBump: MajorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v2.0.0-rc.1",
@ -114,11 +116,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease bump (minor)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.2.0-rc.0"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion,
versionBump: MinorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.2.0-rc.1",
@ -127,11 +129,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease bump (patch)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.2-rc.0"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.1.2-rc.1",
@ -140,11 +142,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease different bump (major)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.2.0-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.2.0-rc.0"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MajorVersion,
versionBump: MajorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v2.0.0-rc.1",
@ -153,11 +155,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease different bump (minor)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.2-rc.0"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.2-rc.0"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.MinorVersion,
versionBump: MinorVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.2.0-rc.1",
@ -166,11 +168,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease to prerelease",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1-alpha.2"},
Stable: &git.Tag{Name: "v1.1.0"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "v1.1.1-rc.0",
@ -179,11 +181,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease to normal (explicit)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1-alpha.2"},
Stable: &git.Tag{Name: "v1.1.0"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeNormal,
},
want: "v1.1.1",
@ -192,11 +194,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "prerelease to normal (implicit)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-alpha.2"},
Stable: &Tag{Name: "v1.1.0"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1-alpha.2"},
Stable: &git.Tag{Name: "v1.1.0"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.1.1",
@ -205,11 +207,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "nil tag (major)",
args: args{
releases: Releases{
releases: git.Releases{
Latest: nil,
Stable: nil,
},
versionBump: conventionalcommits.MajorVersion,
versionBump: MajorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.0.0",
@ -218,11 +220,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "nil tag (minor)",
args: args{
releases: Releases{
releases: git.Releases{
Latest: nil,
Stable: nil,
},
versionBump: conventionalcommits.MinorVersion,
versionBump: MinorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v0.1.0",
@ -231,11 +233,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "nil tag (patch)",
args: args{
releases: Releases{
releases: git.Releases{
Latest: nil,
Stable: nil,
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v0.0.1",
@ -244,11 +246,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "nil stable release (major)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
versionBump: conventionalcommits.MajorVersion,
versionBump: MajorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v2.0.0",
@ -257,11 +259,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "nil stable release (minor)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
versionBump: conventionalcommits.MinorVersion,
versionBump: MinorVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "v1.2.0",
@ -270,11 +272,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "nil stable release (patch)",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.0"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1-rc.0"},
Stable: nil,
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeUndefined,
},
// TODO: Is this actually correct our should it be v1.1.1?
@ -284,11 +286,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "error on invalid tag semver",
args: args{
releases: Releases{
Latest: &Tag{Name: "foodazzle"},
Stable: &Tag{Name: "foodazzle"},
releases: git.Releases{
Latest: &git.Tag{Name: "foodazzle"},
Stable: &git.Tag{Name: "foodazzle"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "",
@ -297,11 +299,11 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "error on invalid tag prerelease",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1-rc.foo"},
Stable: &Tag{Name: "v1.1.1-rc.foo"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1-rc.foo"},
Stable: &git.Tag{Name: "v1.1.1-rc.foo"},
},
versionBump: conventionalcommits.PatchVersion,
versionBump: PatchVersion,
nextVersionType: NextVersionTypeRC,
},
want: "",
@ -310,12 +312,12 @@ func TestReleases_NextVersion(t *testing.T) {
{
name: "error on invalid bump",
args: args{
releases: Releases{
Latest: &Tag{Name: "v1.1.1"},
Stable: &Tag{Name: "v1.1.1"},
releases: git.Releases{
Latest: &git.Tag{Name: "v1.1.1"},
Stable: &git.Tag{Name: "v1.1.1"},
},
versionBump: conventionalcommits.UnknownVersion,
versionBump: UnknownVersion,
nextVersionType: NextVersionTypeUndefined,
},
want: "",
@ -324,7 +326,7 @@ func TestReleases_NextVersion(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := SemVerNextVersion(tt.args.releases, tt.args.versionBump, tt.args.nextVersionType)
got, err := SemVer.NextVersion(tt.args.releases, tt.args.versionBump, tt.args.nextVersionType)
if !tt.wantErr(t, err, fmt.Sprintf("SemVerNextVersion(Releases(%v, %v), %v, %v)", tt.args.releases.Latest, tt.args.releases.Stable, tt.args.versionBump, tt.args.nextVersionType)) {
return
}
@ -336,53 +338,87 @@ func TestReleases_NextVersion(t *testing.T) {
func TestVersionBumpFromCommits(t *testing.T) {
tests := []struct {
name string
analyzedCommits []AnalyzedCommit
want conventionalcommits.VersionBump
analyzedCommits []commitparser.AnalyzedCommit
want VersionBump
}{
{
name: "no entries (unknown)",
analyzedCommits: []AnalyzedCommit{},
want: conventionalcommits.UnknownVersion,
analyzedCommits: []commitparser.AnalyzedCommit{},
want: UnknownVersion,
},
{
name: "non-release type (unknown)",
analyzedCommits: []AnalyzedCommit{{Type: "docs"}},
want: conventionalcommits.UnknownVersion,
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}},
want: UnknownVersion,
},
{
name: "single breaking (major)",
analyzedCommits: []AnalyzedCommit{{BreakingChange: true}},
want: conventionalcommits.MajorVersion,
analyzedCommits: []commitparser.AnalyzedCommit{{BreakingChange: true}},
want: MajorVersion,
},
{
name: "single feat (minor)",
analyzedCommits: []AnalyzedCommit{{Type: "feat"}},
want: conventionalcommits.MinorVersion,
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "feat"}},
want: MinorVersion,
},
{
name: "single fix (patch)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}},
want: conventionalcommits.PatchVersion,
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}},
want: PatchVersion,
},
{
name: "multiple entries (major)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}},
want: conventionalcommits.MajorVersion,
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {BreakingChange: true}},
want: MajorVersion,
},
{
name: "multiple entries (minor)",
analyzedCommits: []AnalyzedCommit{{Type: "fix"}, {Type: "feat"}},
want: conventionalcommits.MinorVersion,
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "fix"}, {Type: "feat"}},
want: MinorVersion,
},
{
name: "multiple entries (patch)",
analyzedCommits: []AnalyzedCommit{{Type: "docs"}, {Type: "fix"}},
want: conventionalcommits.PatchVersion,
analyzedCommits: []commitparser.AnalyzedCommit{{Type: "docs"}, {Type: "fix"}},
want: PatchVersion,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, VersionBumpFromCommits(tt.analyzedCommits), "VersionBumpFromCommits(%v)", tt.analyzedCommits)
assert.Equalf(t, tt.want, BumpFromCommits(tt.analyzedCommits), "BumpFromCommits(%v)", tt.analyzedCommits)
})
}
}
func TestSemVer_IsPrerelease(t *testing.T) {
tests := []struct {
name string
version string
want bool
}{
{
name: "empty string",
version: "",
want: false,
},
{
name: "stable version",
version: "v1.0.0",
want: false,
},
{
name: "pre-release version",
version: "v1.0.0-rc.1+foo",
want: true,
},
{
name: "invalid version",
version: "ajfkdafjdsfj",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equalf(t, tt.want, SemVer.IsPrerelease(tt.version), "IsSemverPrerelease(%v)", tt.version)
})
}
}

View file

@ -0,0 +1,59 @@
package versioning
import (
"github.com/leodido/go-conventionalcommits"
"github.com/apricote/releaser-pleaser/internal/git"
)
type Strategy interface {
NextVersion(git.Releases, VersionBump, NextVersionType) (string, error)
IsPrerelease(version string) bool
}
type VersionBump conventionalcommits.VersionBump
const (
UnknownVersion VersionBump = iota
PatchVersion
MinorVersion
MajorVersion
)
type NextVersionType int
const (
NextVersionTypeUndefined NextVersionType = iota
NextVersionTypeNormal
NextVersionTypeRC
NextVersionTypeBeta
NextVersionTypeAlpha
)
func (n NextVersionType) String() string {
switch n {
case NextVersionTypeUndefined:
return "undefined"
case NextVersionTypeNormal:
return "normal"
case NextVersionTypeRC:
return "rc"
case NextVersionTypeBeta:
return "beta"
case NextVersionTypeAlpha:
return "alpha"
default:
return ""
}
}
func (n NextVersionType) IsPrerelease() bool {
switch n {
case NextVersionTypeRC, NextVersionTypeBeta, NextVersionTypeAlpha:
return true
case NextVersionTypeUndefined, NextVersionTypeNormal:
return false
default:
return false
}
}

25
mise.toml Normal file
View file

@ -0,0 +1,25 @@
[tools]
go = "1.25.5"
golangci-lint = "2.8.0"
goreleaser = "v2.9.0"
mdbook = "v0.5.2" # renovate: datasource=github-releases depName=rust-lang/mdbook
ko = "v0.18.1" # renovate: datasource=github-releases depName=ko-build/ko
[settings]
# Experimental features are needed for the Go backend
experimental = true
[tasks.lint]
run = "golangci-lint run"
[tasks.test]
run = "go test -v -race ./..."
[tasks.test-e2e]
run = "go test -tags e2e_forgejo -v -race ./test/e2e/forgejo"
[tasks.e2e-forgejo-start]
run = "docker compose --project-directory ./test/e2e/forgejo up -d --wait"
[tasks.e2e-forgejo-stop]
run = "docker compose --project-directory ./test/e2e/forgejo down"

57
prbody.go Normal file
View file

@ -0,0 +1,57 @@
package rp
import (
"strings"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/markdown"
)
func parsePRBodyForCommitOverrides(commits []git.Commit) ([]git.Commit, error) {
result := make([]git.Commit, 0, len(commits))
for _, commit := range commits {
singleResult, err := parseSinglePRBodyForCommitOverrides(commit)
if err != nil {
return nil, err
}
result = append(result, singleResult...)
}
return result, nil
}
func parseSinglePRBodyForCommitOverrides(commit git.Commit) ([]git.Commit, error) {
if commit.PullRequest == nil {
return []git.Commit{commit}, nil
}
source := []byte(commit.PullRequest.Description)
var overridesText string
var found bool
err := markdown.WalkAST(source, markdown.GetCodeBlockText(source, "rp-commits", &overridesText, &found))
if err != nil {
return nil, err
}
if !found {
return []git.Commit{commit}, nil
}
lines := strings.Split(overridesText, "\n")
result := make([]git.Commit, 0, len(lines))
for _, line := range lines {
// Only consider lines with text
line = strings.TrimSpace(line)
if line == "" {
continue
}
newCommit := commit
newCommit.Message = line
result = append(result, newCommit)
}
return result, nil
}

242
prbody_test.go Normal file
View file

@ -0,0 +1,242 @@
package rp
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/git"
)
func Test_parsePRBodyForCommitOverrides(t *testing.T) {
tests := []struct {
name string
commits []git.Commit
want []git.Commit
wantErr assert.ErrorAssertionFunc
}{
{
name: "no commits",
commits: []git.Commit{},
want: []git.Commit{},
wantErr: assert.NoError,
},
{
name: "single commit",
commits: []git.Commit{
{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n",
},
},
},
want: []git.Commit{
{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n",
},
},
},
wantErr: assert.NoError,
},
{
name: "multiple commits",
commits: []git.Commit{
{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\nfeat: shiny\nfix: boom\n```\n",
},
},
{
Hash: "456",
Message: "654",
PullRequest: &git.PullRequest{
ID: 2,
Title: "Bar",
Description: "# Foobazzle\n\n",
},
},
},
want: []git.Commit{
{
Hash: "123",
Message: "feat: shiny",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\nfeat: shiny\nfix: boom\n```\n",
},
},
{
Hash: "123",
Message: "fix: boom",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\nfeat: shiny\nfix: boom\n```\n",
},
},
{
Hash: "456",
Message: "654",
PullRequest: &git.PullRequest{
ID: 2,
Title: "Bar",
Description: "# Foobazzle\n\n",
},
},
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parsePRBodyForCommitOverrides(tt.commits)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func Test_parseSinglePRBodyForCommitOverrides(t *testing.T) {
tests := []struct {
name string
commit git.Commit
want []git.Commit
wantErr assert.ErrorAssertionFunc
}{
{
name: "same commit if no PR is available",
commit: git.Commit{
Hash: "123",
Message: "321",
PullRequest: nil,
},
want: []git.Commit{
{
Hash: "123",
Message: "321",
},
},
wantErr: assert.NoError,
},
{
name: "same commit if no overrides are defined",
commit: git.Commit{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n",
},
},
want: []git.Commit{
{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n",
},
},
},
wantErr: assert.NoError,
},
{
name: "no commit if override is defined but empty",
commit: git.Commit{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "```rp-commits\n```\n",
},
},
want: []git.Commit{},
wantErr: assert.NoError,
},
{
name: "commit messages from override",
commit: git.Commit{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\nfeat: shiny\nfix: boom\n```\n",
},
},
want: []git.Commit{
{
Hash: "123",
Message: "feat: shiny",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\nfeat: shiny\nfix: boom\n```\n",
},
},
{
Hash: "123",
Message: "fix: boom",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\nfeat: shiny\nfix: boom\n```\n",
},
},
},
wantErr: assert.NoError,
},
{
name: "ignore empty lines",
commit: git.Commit{
Hash: "123",
Message: "321",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\n\n \nfeat: shiny\n\n```\n",
},
},
want: []git.Commit{
{
Hash: "123",
Message: "feat: shiny",
PullRequest: &git.PullRequest{
ID: 1,
Title: "Foo",
Description: "# Cool new thingy\n\n```rp-commits\n\n \nfeat: shiny\n\n```\n",
},
},
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseSinglePRBodyForCommitOverrides(tt.commit)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}

View file

@ -1,284 +0,0 @@
package rp
import (
"bytes"
_ "embed"
"fmt"
"log"
"regexp"
"strings"
"text/template"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/text"
"github.com/apricote/releaser-pleaser/internal/markdown"
east "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
)
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)
}
}
// ReleasePullRequest
//
// TODO: Reuse [PullRequest]
type ReleasePullRequest struct {
ID int
Title string
Description string
Labels []Label
Head string
ReleaseCommit *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
// TODO: Doing the changelog for normal releases after previews requires to know about this while fetching the commits
NextVersionType NextVersionType
}
type NextVersionType int
const (
NextVersionTypeUndefined NextVersionType = iota
NextVersionTypeNormal
NextVersionTypeRC
NextVersionTypeBeta
NextVersionTypeAlpha
)
func (n NextVersionType) String() string {
switch n {
case NextVersionTypeUndefined:
return "undefined"
case NextVersionTypeNormal:
return "normal"
case NextVersionTypeRC:
return "rc"
case NextVersionTypeBeta:
return "beta"
case NextVersionTypeAlpha:
return "alpha"
default:
return ""
}
}
// Label is the string identifier of a pull/merge request label on the forge.
type Label string
const (
LabelNextVersionTypeNormal Label = "rp-next-version::normal"
LabelNextVersionTypeRC Label = "rp-next-version::rc"
LabelNextVersionTypeBeta Label = "rp-next-version::beta"
LabelNextVersionTypeAlpha Label = "rp-next-version::alpha"
LabelReleasePending Label = "rp-release::pending"
LabelReleaseTagged Label = "rp-release::tagged"
)
var KnownLabels = []Label{
LabelNextVersionTypeNormal,
LabelNextVersionTypeRC,
LabelNextVersionTypeBeta,
LabelNextVersionTypeAlpha,
LabelReleasePending,
LabelReleaseTagged,
}
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 = NextVersionTypeNormal
case LabelNextVersionTypeRC:
overrides.NextVersionType = NextVersionTypeRC
case LabelNextVersionTypeBeta:
overrides.NextVersionType = NextVersionTypeBeta
case LabelNextVersionTypeAlpha:
overrides.NextVersionType = NextVersionTypeAlpha
case LabelReleasePending, LabelReleaseTagged:
// These labels have no effect on the versioning.
break
}
}
return overrides
}
func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (ReleaseOverrides, error) {
source := []byte(pr.Description)
descriptionAST := markdown.New().Parser().Parse(text.NewReader(source))
err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if n.Type() != ast.TypeBlock || n.Kind() != ast.KindFencedCodeBlock {
return ast.WalkContinue, nil
}
codeBlock, ok := n.(*ast.FencedCodeBlock)
if !ok {
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
}
switch string(codeBlock.Language(source)) {
case DescriptionLanguagePrefix:
overrides.Prefix = textFromLines(source, codeBlock)
case DescriptionLanguageSuffix:
overrides.Suffix = textFromLines(source, codeBlock)
}
return ast.WalkContinue, nil
})
if err != nil {
return ReleaseOverrides{}, err
}
return overrides, nil
}
func (pr *ReleasePullRequest) ChangelogText() (string, error) {
source := []byte(pr.Description)
gm := markdown.New()
descriptionAST := gm.Parser().Parse(text.NewReader(source))
var section *east.Section
err := ast.Walk(descriptionAST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
if n.Type() != ast.TypeBlock || n.Kind() != east.KindSection {
return ast.WalkContinue, nil
}
anySection, ok := n.(*east.Section)
if !ok {
return ast.WalkStop, fmt.Errorf("node has unexpected type: %T", n)
}
if anySection.Name != MarkdownSectionChangelog {
return ast.WalkContinue, nil
}
section = anySection
return ast.WalkStop, nil
})
if err != nil {
return "", err
}
if section == nil {
return "", nil
}
outputBuffer := new(bytes.Buffer)
err = gm.Renderer().Render(outputBuffer, source, section)
if err != nil {
return "", err
}
return outputBuffer.String(), nil
}
func textFromLines(source []byte, n ast.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 (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

@ -1,144 +0,0 @@
package rp
import (
"testing"
"github.com/stretchr/testify/assert"
)
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{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: `<!-- 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>
`,
wantErr: assert.NoError,
},
{
name: "existing overrides",
changelogEntry: `## v1.0.0`,
overrides: ReleaseOverrides{
Prefix: "This release is awesome!",
Suffix: "Fooo",
},
want: `<!-- 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
This release is awesome!
` + "```" + `
### Suffix / End
This will be added to the end of the release notes.
` + "```" + `rp-suffix
Fooo
` + "```" + `
</details>
`,
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

@ -2,37 +2,48 @@ package rp
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"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/apricote/releaser-pleaser/internal/changelog"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/forge"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/releasepr"
"github.com/apricote/releaser-pleaser/internal/updater"
"github.com/apricote/releaser-pleaser/internal/versioning"
)
const (
PullRequestBranchFormat = "releaser-pleaser--branches--%s"
)
const (
PullRequestConflictAttempts = 3
)
var (
ErrorPullRequestConflict = errors.New("conflict: pull request description was changed while releaser-pleaser was running")
)
type ReleaserPleaser struct {
forge Forge
forge forge.Forge
logger *slog.Logger
targetBranch string
commitParser CommitParser
nextVersion VersioningStrategy
commitParser commitparser.CommitParser
versioning versioning.Strategy
extraFiles []string
updaters []Updater
updaters []updater.Updater
}
func New(forge Forge, logger *slog.Logger, targetBranch string, commitParser CommitParser, versioningStrategy VersioningStrategy, extraFiles []string, updaters []Updater) *ReleaserPleaser {
func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.Updater) *ReleaserPleaser {
return &ReleaserPleaser{
forge: forge,
logger: logger,
targetBranch: targetBranch,
commitParser: commitParser,
nextVersion: versioningStrategy,
versioning: versioningStrategy,
extraFiles: extraFiles,
updaters: updaters,
}
@ -40,7 +51,8 @@ func New(forge Forge, logger *slog.Logger, targetBranch string, commitParser Com
func (rp *ReleaserPleaser) EnsureLabels(ctx context.Context) error {
// TODO: Wrap Error
return rp.forge.EnsureLabelsExist(ctx, KnownLabels)
return rp.forge.EnsureLabelsExist(ctx, releasepr.KnownLabels)
}
func (rp *ReleaserPleaser) Run(ctx context.Context) error {
@ -54,7 +66,7 @@ func (rp *ReleaserPleaser) Run(ctx context.Context) error {
return fmt.Errorf("failed to create pending releases: %w", err)
}
err = rp.runReconcileReleasePR(ctx)
err = rp.runReconcileReleasePRWithRetries(ctx)
if err != nil {
return fmt.Errorf("failed to reconcile release pull request: %w", err)
}
@ -75,7 +87,7 @@ func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error {
logger := rp.logger.With("method", "runCreatePendingReleases")
logger.InfoContext(ctx, "checking for pending releases")
prs, err := rp.forge.PendingReleases(ctx, LabelReleasePending)
prs, err := rp.forge.PendingReleases(ctx, releasepr.LabelReleasePending)
if err != nil {
return err
}
@ -97,7 +109,7 @@ func (rp *ReleaserPleaser) runCreatePendingReleases(ctx context.Context) error {
return nil
}
func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *ReleasePullRequest) error {
func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
logger := rp.logger.With(
"method", "createPendingRelease",
"pr.id", pr.ID,
@ -114,22 +126,22 @@ func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *Release
return err
}
changelog, err := pr.ChangelogText()
changelogText, err := pr.ChangelogText()
if err != nil {
return err
}
// TODO: pre-release & latest
// TODO: Check if version should be marked latest
logger.DebugContext(ctx, "Creating release on forge")
err = rp.forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelog, false, true)
err = rp.forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelogText, rp.versioning.IsPrerelease(version), true)
if err != nil {
return fmt.Errorf("failed to create release on forge: %w", err)
}
logger.DebugContext(ctx, "created release", "release.title", version, "release.url", rp.forge.ReleaseURL(version))
logger.DebugContext(ctx, "updating pr labels")
err = rp.forge.SetPullRequestLabels(ctx, pr, []Label{LabelReleasePending}, []Label{LabelReleaseTagged})
err = rp.forge.SetPullRequestLabels(ctx, pr, []releasepr.Label{releasepr.LabelReleasePending}, []releasepr.Label{releasepr.LabelReleaseTagged})
if err != nil {
return err
}
@ -140,9 +152,58 @@ func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *Release
return nil
}
// runReconcileReleasePRWithRetries retries runReconcileReleasePR up to PullRequestConflictAttempts times, but only
// when a ErrorPullRequestConflict was encountered.
func (rp *ReleaserPleaser) runReconcileReleasePRWithRetries(ctx context.Context) error {
logger := rp.logger.With("method", "runReconcileReleasePRWithRetries", "totalAttempts", PullRequestConflictAttempts)
var err error
for i := range PullRequestConflictAttempts {
logger := logger.With("attempt", i+1)
logger.DebugContext(ctx, "attempting runReconcileReleasePR")
err = rp.runReconcileReleasePR(ctx)
if err != nil {
if errors.Is(err, ErrorPullRequestConflict) {
logger.WarnContext(ctx, "detected conflict while updating pull request description, retrying")
continue
}
break
}
break
}
if err != nil {
return err
}
return nil
}
func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
logger := rp.logger.With("method", "runReconcileReleasePR")
rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch)
pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch)
if err != nil {
return err
}
var releaseOverrides releasepr.ReleaseOverrides
if pr != nil {
logger = logger.With("pr.id", pr.ID, "pr.title", pr.Title)
logger.InfoContext(ctx, "found existing release pull request")
releaseOverrides, err = pr.GetOverrides()
if err != nil {
return err
}
}
releases, err := rp.forge.LatestTags(ctx)
if err != nil {
return err
@ -157,38 +218,16 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
logger.InfoContext(ctx, "no latest tag found")
}
releasableCommits, err := rp.forge.CommitsSince(ctx, releases.Stable)
// For stable releases, we want to consider all changes since the last stable release for version and changelog.
// For prereleases, we want to consider all changes...
// - since the last stable release for the version
// - since the latest release (stable or prerelease) for the changelog
analyzedCommitsForVersioning, err := rp.analyzedCommitsSince(ctx, releases.Stable)
if err != nil {
return err
}
logger.InfoContext(ctx, "Found releasable commits", "length", len(releasableCommits))
// TODO: Handle commit overrides
analyzedCommits, err := rp.commitParser.Analyze(releasableCommits)
if err != nil {
return err
}
logger.InfoContext(ctx, "Analyzed commits", "length", len(analyzedCommits))
rpBranch := fmt.Sprintf(PullRequestBranchFormat, rp.targetBranch)
rpBranchRef := plumbing.NewBranchReferenceName(rpBranch)
// Check Forge for open PR
// Get any modifications from open PR
// Clone Repo
// Run Updaters + Changelog
// Upsert PR
pr, err := rp.forge.PullRequestForBranch(ctx, rpBranch)
if err != nil {
return err
}
if pr != nil {
logger.InfoContext(ctx, "found existing release pull request", "pr.id", pr.ID, "pr.title", pr.Title)
}
if len(analyzedCommits) == 0 {
if len(analyzedCommitsForVersioning) == 0 {
if pr != nil {
logger.InfoContext(ctx, "closing existing pull requests, no commits available", "pr.id", pr.ID, "pr.title", pr.Title)
err = rp.forge.ClosePullRequest(ctx, pr)
@ -202,178 +241,95 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
return nil
}
var releaseOverrides ReleaseOverrides
if pr != nil {
releaseOverrides, err = pr.GetOverrides()
if err != nil {
return err
}
}
versionBump := VersionBumpFromCommits(analyzedCommits)
versionBump := versioning.BumpFromCommits(analyzedCommitsForVersioning)
// TODO: Set version in release pr
nextVersion, err := rp.nextVersion(releases, versionBump, releaseOverrides.NextVersionType)
nextVersion, err := rp.versioning.NextVersion(releases, versionBump, releaseOverrides.NextVersionType)
if err != nil {
return err
}
logger.InfoContext(ctx, "next version", "version", nextVersion)
logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL())
repo, err := CloneRepo(ctx, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth())
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
if branch, _ := repo.Branch(rpBranch); branch != nil {
logger.DebugContext(ctx, "deleting previous releaser-pleaser branch locally", "branch.name", rpBranch)
if err = repo.DeleteBranch(rpBranch); err != nil {
analyzedCommitsForChangelog := analyzedCommitsForVersioning
if releaseOverrides.NextVersionType.IsPrerelease() && releases.Latest != releases.Stable {
analyzedCommitsForChangelog, err = rp.analyzedCommitsSince(ctx, releases.Latest)
if err != nil {
return err
}
}
if err = worktree.Checkout(&git.CheckoutOptions{
Branch: rpBranchRef,
Create: true,
}); err != nil {
return fmt.Errorf("failed to check out branch: %w", err)
logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL())
repo, err := git.CloneRepo(ctx, logger, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth())
if err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
changelogEntry, err := NewChangelogEntry(analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix)
if err = repo.DeleteBranch(ctx, rpBranch); err != nil {
return err
}
if err = repo.Checkout(ctx, rpBranch); err != nil {
return err
}
changelogData := changelog.New(commitparser.ByType(analyzedCommitsForChangelog), nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix)
changelogEntry, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{})
if err != nil {
return fmt.Errorf("failed to build changelog entry: %w", err)
}
// Info for updaters
info := ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry}
info := updater.ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry}
updateFile := func(path string, updaters []Updater) error {
file, err := worktree.Filesystem.OpenFile(path, os.O_RDWR, 0)
if err != nil {
return err
}
defer file.Close()
content, err := io.ReadAll(file)
if err != nil {
return err
}
updatedContent := string(content)
for _, updater := range updaters {
updatedContent, err = updater.UpdateContent(updatedContent, info)
for _, u := range rp.updaters {
for _, file := range u.Files() {
err = repo.UpdateFile(ctx, file, u.CreateNewFiles(), u.Update(info))
if err != nil {
return fmt.Errorf("failed to run updater %T on file %s", updater, path)
return fmt.Errorf("failed to run updater %T: %w", u, err)
}
}
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
}
err = updateFile(ChangelogFile, []Updater{&ChangelogUpdater{}})
releaseCommitAuthor, err := rp.forge.CommitAuthor(ctx)
if err != nil {
return fmt.Errorf("failed to update changelog file: %w", err)
}
for _, path := range rp.extraFiles {
_, err = worktree.Filesystem.Stat(path)
if err != nil {
// TODO: Check for non existing file or dirs
return fmt.Errorf("failed to run file updater because the file %s does not exist: %w", path, err)
}
err = updateFile(path, rp.updaters)
if err != nil {
return fmt.Errorf("failed to run file updater: %w", err)
}
return fmt.Errorf("failed to get commit author: %w", err)
}
releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion)
releaseCommitHash, err := worktree.Commit(releaseCommitMessage, &git.CommitOptions{
Author: GitSignature(),
Committer: GitSignature(),
})
releaseCommit, err := repo.Commit(ctx, releaseCommitMessage, releaseCommitAuthor)
if err != nil {
return fmt.Errorf("failed to commit changes: %w", err)
}
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommitHash.String(), "commit.message", releaseCommitMessage)
newReleasePRChanges := true
logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message, "commit.author", releaseCommitAuthor)
// Check if anything changed in comparison to the remote branch (if exists)
if remoteRef, err := repo.Reference(plumbing.NewRemoteReferenceName(GitRemoteName, rpBranch), false); err != nil {
if err.Error() != "reference not found" {
// "reference not found" is expected and we should always push
return err
}
} else {
remoteCommit, err := repo.CommitObject(remoteRef.Hash())
if err != nil {
return err
}
localCommit, err := repo.CommitObject(releaseCommitHash)
if err != nil {
return err
}
diff, err := localCommit.PatchContext(ctx, remoteCommit)
if err != nil {
return err
}
newReleasePRChanges = len(diff.FilePatches()) > 0
newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rp.targetBranch, rpBranch)
if err != nil {
return err
}
if newReleasePRChanges {
pushRefSpec := config.RefSpec(fmt.Sprintf(
"+%s:%s",
rpBranchRef,
// This needs to be the local branch name, not the remotes/origin ref
// See https://stackoverflow.com/a/75727620
rpBranchRef,
))
logger.DebugContext(ctx, "pushing branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String())
if err = repo.PushContext(ctx, &git.PushOptions{
RemoteName: GitRemoteName,
RefSpecs: []config.RefSpec{pushRefSpec},
Force: true,
Auth: rp.forge.GitAuth(),
}); err != nil {
err = repo.ForcePush(ctx, rpBranch)
if err != nil {
return fmt.Errorf("failed to push branch: %w", err)
}
logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommitHash.String(), "branch.name", rpBranch, "refspec", pushRefSpec.String())
logger.InfoContext(ctx, "pushed branch", "commit.hash", releaseCommit.Hash, "branch.name", rpBranch)
} else {
logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push")
}
// We do not need the version title here. In the pull request the version is available from the title, and in the
// release on the Forge its usually in a heading somewhere above the text.
changelogEntryPullRequest, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{HideVersionTitle: true})
if err != nil {
return fmt.Errorf("failed to build pull request changelog entry: %w", err)
}
// Open/Update PR
if pr == nil {
pr, err = NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntry)
pr, err = releasepr.NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntryPullRequest)
if err != nil {
return err
}
@ -382,15 +338,32 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
if err != nil {
return err
}
logger.InfoContext(ctx, "opened pull request", "pr.title", pr.Title, "pr.id", pr.ID)
logger.InfoContext(ctx, "opened pull request", "pr.title", pr.Title, "pr.id", pr.ID, "pr.url", rp.forge.PullRequestURL(pr.ID))
} else {
// Check if the pull request was updated while releaser-pleaser was running.
// This avoids a conflict where the user updated the PR while releaser-pleaser already pulled the info, and
// releaser-pleaser subsequently reverts the users changes. There is still a minimal time window for this to
// happen between us checking the PR again and submitting our changes.
logger.DebugContext(ctx, "checking for conflict in pr description", "pr.id", pr.ID)
recheckPR, err := rp.forge.PullRequestForBranch(ctx, rpBranch)
if err != nil {
return err
}
if recheckPR == nil {
return fmt.Errorf("PR was deleted while releaser-pleaser was running")
}
if recheckPR.Description != pr.Description {
return ErrorPullRequestConflict
}
pr.SetTitle(rp.targetBranch, nextVersion)
overrides, err := pr.GetOverrides()
if err != nil {
return err
}
err = pr.SetDescription(changelogEntry, overrides)
err = pr.SetDescription(changelogEntryPullRequest, overrides)
if err != nil {
return err
}
@ -399,8 +372,37 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
if err != nil {
return err
}
logger.InfoContext(ctx, "updated pull request", "pr.title", pr.Title, "pr.id", pr.ID)
logger.InfoContext(ctx, "updated pull request", "pr.title", pr.Title, "pr.id", pr.ID, "pr.url", rp.forge.PullRequestURL(pr.ID))
}
return nil
}
func (rp *ReleaserPleaser) analyzedCommitsSince(ctx context.Context, since *git.Tag) ([]commitparser.AnalyzedCommit, error) {
logger := rp.logger.With("method", "analyzedCommitsSince")
if since != nil {
logger = rp.logger.With("tag.hash", since.Hash, "tag.name", since.Name)
}
commits, err := rp.forge.CommitsSince(ctx, since)
if err != nil {
return nil, err
}
commits, err = parsePRBodyForCommitOverrides(commits)
if err != nil {
return nil, err
}
logger.InfoContext(ctx, "Found releasable commits", "length", len(commits))
analyzedCommits, err := rp.commitParser.Analyze(commits)
if err != nil {
return nil, err
}
logger.InfoContext(ctx, "Analyzed commits", "length", len(analyzedCommits))
return analyzedCommits, nil
}

57
templates/run.yml Normal file
View file

@ -0,0 +1,57 @@
spec:
inputs:
# Remember to update docs/reference/gitlab-ci-component.md
branch:
default: main
description: "This branch is used as the target for releases."
token:
description: "GitLab token for creating and updating release MRs."
extra-files:
description: 'List of files that are scanned for version references by the generic updater.'
default: ""
updaters:
description: "List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic"
default: ""
stage:
default: build
description: 'Defines the build stage'
needs:
default: [ ]
type: array
description: 'Dependencies of the created Job'
# Remember to update docs/reference/gitlab-ci-component.md
---
releaser-pleaser:
stage: $[[ inputs.stage ]]
needs: $[[ inputs.needs ]]
rules:
# There is no way to run a pipeline when the MR description is updated :(
- if: $CI_COMMIT_BRANCH == "$[[ inputs.branch ]]"
# If a newer releaser-pleaser job runs, this one may be cancelled without problem, releaser-pleaser is idempotent.
# This only works if the user enables "auto-cancel redundant pipelines", which we do tell them to, because this is
# intrusive and up to the user.
interruptible: true
# No need to have multiple releaser-pleaser jobs running at the same time. They all act on the same global state.
resource_group: releaser-pleaser
image:
name: ghcr.io/apricote/releaser-pleaser:v0.7.1 # x-releaser-pleaser-version
entrypoint: [ "" ]
variables:
GITLAB_TOKEN: $[[ inputs.token ]]
script:
- |
rp run \
--forge=gitlab \
--branch=$[[ inputs.branch ]] \
--extra-files="$[[ inputs.extra-files ]]" \
--updaters="$[[ inputs.updaters ]]"

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