From eae0045359e44bd3fca1c8f543657baa13bfd5f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 14 Jun 2025 15:11:28 +0200 Subject: [PATCH 1/7] feat: colorize log output (#195) Makes it easier to read, uses lmittmann/tint. --- cmd/rp/cmd/root.go | 12 +++++++++--- go.mod | 1 + go.sum | 2 ++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cmd/rp/cmd/root.go b/cmd/rp/cmd/root.go index 4da80e5..14a5d14 100644 --- a/cmd/rp/cmd/root.go +++ b/cmd/rp/cmd/root.go @@ -4,7 +4,9 @@ import ( "log/slog" "os" "runtime/debug" + "time" + "github.com/lmittmann/tint" "github.com/spf13/cobra" ) @@ -46,8 +48,12 @@ func Execute() { } func init() { - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelDebug, - })) + logger = slog.New( + tint.NewHandler(os.Stderr, &tint.Options{ + Level: slog.LevelDebug, + TimeFormat: time.RFC3339, + }), + ) + slog.SetDefault(logger) } diff --git a/go.mod b/go.mod index 341b585..395b57f 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 518418d..9fd54cc 100644 --- a/go.sum +++ b/go.sum @@ -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= From 08d35f2f572e88b6638f7c226b9d0213af76a785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 14 Jun 2025 15:19:34 +0200 Subject: [PATCH 2/7] 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. --- cmd/rp/cmd/root.go | 40 +++++++++++++++++++++++++++++++++++----- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/cmd/rp/cmd/root.go b/cmd/rp/cmd/root.go index 14a5d14..2799e6d 100644 --- a/cmd/rp/cmd/root.go +++ b/cmd/rp/cmd/root.go @@ -1,9 +1,12 @@ package cmd import ( + "context" "log/slog" "os" + "os/signal" "runtime/debug" + "syscall" "time" "github.com/lmittmann/tint" @@ -13,10 +16,12 @@ import ( var logger *slog.Logger var rootCmd = &cobra.Command{ - Use: "rp", - Short: "", - Long: ``, - Version: version(), + Use: "rp", + Short: "", + Long: ``, + Version: version(), + SilenceUsage: true, // Makes it harder to find the actual error + SilenceErrors: true, // We log manually with slog } func version() string { @@ -41,8 +46,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() + logger.InfoContext(ctx, "Received shutdown signal, stopping...") + stop() + }() + + err := rootCmd.ExecuteContext(ctx) if err != nil { + logger.ErrorContext(ctx, err.Error()) os.Exit(1) } } From d24ae7de98ae7590bc191c67a2357c059d33fc9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 14 Jun 2025 15:23:05 +0200 Subject: [PATCH 3/7] 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 --- releaserpleaser.go | 58 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/releaserpleaser.go b/releaserpleaser.go index 409d8bb..a09aefe 100644 --- a/releaserpleaser.go +++ b/releaserpleaser.go @@ -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() From 2d3a9609390e1646b39dd1d64ba5825f5f77599a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 14 Jun 2025 15:43:35 +0200 Subject: [PATCH 4/7] 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. --- .github/workflows/releaser-pleaser.yaml | 7 +++++++ docs/tutorials/github.md | 4 ++++ templates/run.yml | 10 ++++++++++ 3 files changed, 21 insertions(+) diff --git a/.github/workflows/releaser-pleaser.yaml b/.github/workflows/releaser-pleaser.yaml index 6898924..f8d1aa5 100644 --- a/.github/workflows/releaser-pleaser.yaml +++ b/.github/workflows/releaser-pleaser.yaml @@ -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: diff --git a/docs/tutorials/github.md b/docs/tutorials/github.md index 693ef65..02812b2 100644 --- a/docs/tutorials/github.md +++ b/docs/tutorials/github.md @@ -44,6 +44,10 @@ on: - labeled - unlabeled +concurrency: + group: releaser-pleaser + cancel-in-progress: true + jobs: releaser-pleaser: runs-on: ubuntu-latest diff --git a/templates/run.yml b/templates/run.yml index cbde7ad..9382d79 100644 --- a/templates/run.yml +++ b/templates/run.yml @@ -26,9 +26,19 @@ 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 entrypoint: [ "" ] From d540e2221dc710058f9a786c8d6c8a17049fca6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 14 Jun 2025 16:24:16 +0200 Subject: [PATCH 5/7] 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. --- docs/SUMMARY.md | 1 + docs/explanation/concurrency-conflicts.md | 65 +++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 docs/explanation/concurrency-conflicts.md diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index d001c2d..b13bf24 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -10,6 +10,7 @@ # Explanation - [Release Pull Request](explanation/release-pr.md) +- [Concurrency and Conflicts](explanation/concurrency-conflicts.md) # Guides diff --git a/docs/explanation/concurrency-conflicts.md b/docs/explanation/concurrency-conflicts.md new file mode 100644 index 0000000..9d14eb7 --- /dev/null +++ b/docs/explanation/concurrency-conflicts.md @@ -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) + From 0de242a4e6f224e2bb0366e04576aedd40de20c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 14 Jun 2025 16:34:26 +0200 Subject: [PATCH 6/7] 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. --- .github/workflows/releaser-pleaser.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/releaser-pleaser.yaml b/.github/workflows/releaser-pleaser.yaml index f8d1aa5..aa5097f 100644 --- a/.github/workflows/releaser-pleaser.yaml +++ b/.github/workflows/releaser-pleaser.yaml @@ -38,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" From fc1ee70c28f94391de0d5126427f85858f74fef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 14 Jun 2025 16:47:17 +0200 Subject: [PATCH 7/7] chore(main): release v0.6.0 (#189) --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ action.yml | 2 +- templates/run.yml | 2 +- 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cc4b81..f860642 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/action.yml b/action.yml index a1c5de1..07be63f 100644 --- a/action.yml +++ b/action.yml @@ -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 diff --git a/templates/run.yml b/templates/run.yml index 9382d79..e6ffe6d 100644 --- a/templates/run.yml +++ b/templates/run.yml @@ -40,7 +40,7 @@ releaser-pleaser: 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 ]]