Compare commits

..

7 commits

Author SHA1 Message Date
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
11 changed files with 233 additions and 12 deletions

View file

@ -10,6 +10,13 @@ on:
- labeled
- unlabeled
# 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:
@ -31,7 +38,7 @@ jobs:
# Without this, any new flags in `action.yml` would break the job in this repository until the new
# version is released. But a new version can only be released if this job works.
- uses: ko-build/setup-ko@d006021bd0c28d1ce33a07e7943d48b079944c8d # v0.9
- run: ko build --bare --local --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: docker://ghcr.io/apricote/releaser-pleaser:ci|g' action.yml"

View file

@ -1,5 +1,44 @@
# Changelog
## [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 <>`. 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

View file

@ -21,7 +21,7 @@ inputs:
outputs: {}
runs:
using: 'docker'
image: docker://ghcr.io/apricote/releaser-pleaser:v0.5.1 # x-releaser-pleaser-version
image: docker://ghcr.io/apricote/releaser-pleaser:v0.6.0 # x-releaser-pleaser-version
args:
- run
- --forge=github

View file

@ -1,10 +1,15 @@
package cmd
import (
"context"
"log/slog"
"os"
"os/signal"
"runtime/debug"
"syscall"
"time"
"github.com/lmittmann/tint"
"github.com/spf13/cobra"
)
@ -15,6 +20,8 @@ var rootCmd = &cobra.Command{
Short: "",
Long: ``,
Version: version(),
SilenceUsage: true, // Makes it harder to find the actual error
SilenceErrors: true, // We log manually with slog
}
func version() string {
@ -39,15 +46,44 @@ 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()
logger.InfoContext(ctx, "Received shutdown signal, stopping...")
stop()
}()
err := rootCmd.ExecuteContext(ctx)
if err != nil {
logger.ErrorContext(ctx, err.Error())
os.Exit(1)
}
}
func init() {
logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
logger = slog.New(
tint.NewHandler(os.Stderr, &tint.Options{
Level: slog.LevelDebug,
}))
TimeFormat: time.RFC3339,
}),
)
slog.SetDefault(logger)
}

View file

@ -10,6 +10,7 @@
# Explanation
- [Release Pull Request](explanation/release-pr.md)
- [Concurrency and Conflicts](explanation/concurrency-conflicts.md)
# Guides

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

@ -44,6 +44,10 @@ on:
- labeled
- unlabeled
concurrency:
group: releaser-pleaser
cancel-in-progress: true
jobs:
releaser-pleaser:
runs-on: ubuntu-latest

1
go.mod
View file

@ -10,6 +10,7 @@ require (
github.com/go-git/go-git/v5 v5.16.2
github.com/google/go-github/v72 v72.0.0
github.com/leodido/go-conventionalcommits v0.12.0
github.com/lmittmann/tint v1.1.2
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/teekennedy/goldmark-markdown v0.5.1

2
go.sum
View file

@ -65,6 +65,8 @@ 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/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=

View file

@ -2,6 +2,7 @@ package rp
import (
"context"
"errors"
"fmt"
"log/slog"
@ -18,6 +19,14 @@ 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
logger *slog.Logger
@ -57,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)
}
@ -143,6 +152,36 @@ 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")
@ -305,6 +344,23 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error {
}
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()

View file

@ -26,11 +26,21 @@ spec:
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.5.1 # x-releaser-pleaser-version
name: ghcr.io/apricote/releaser-pleaser:v0.6.0 # x-releaser-pleaser-version
entrypoint: [ "" ]
variables:
GITLAB_TOKEN: $[[ inputs.token ]]