From 2c7e3f37f8631484100b7866b4e8a705bb3eaec1 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 26 Oct 2026 10:04:03 +0000 Subject: [PATCH 1/2] ci: refresh the release PR on every push to main, without waiting for CI A merged change reached the release PR only after release.yml's CI, about 30 minutes later and longer when an earlier run was still queued ahead of it, because release.yml's workflow-level concurrency group serializes runs. release-pr.yml runs the PR pass (skip-github-release) on every push to main at once. It only rewrites the release PR. The release is still cut only after CI passes on the commit that merges the release PR, and that commit holds every change the release contains. The two states in which the pass must not compute a PR are covered. release-please aborts on its own while a merged release PR is untagged. The new workflow skips while any draft release exists, since a draft is invisible to release-please's latest-release lookup. release.yml's release-please-pr job now runs only after publish-release, to open the next PR. Both jobs share one concurrency group, so they never rewrite the PR branch at once. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/RELEASES.md | 49 ++++++++++++------------ .github/workflows/release-pr.yml | 65 ++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 29 +++++++------- docs/ci-overview.md | 16 ++++++-- 4 files changed, 116 insertions(+), 43 deletions(-) create mode 100644 .github/workflows/release-pr.yml diff --git a/.github/RELEASES.md b/.github/RELEASES.md index 45bb92819..b6520f3c1 100644 --- a/.github/RELEASES.md +++ b/.github/RELEASES.md @@ -32,9 +32,9 @@ git push origin main Expected outcome: -1. CI runs (lint, unit tests, e2e) -2. If tests pass → Release PR created for v0.2.0 -3. Merge PR → GitHub Release + Docker images published +1. Release PR created or refreshed for v0.2.0 within a minute, without waiting for CI +2. CI runs (lint, unit tests, e2e) +3. Merge PR → once CI passes on the merge, GitHub Release + Docker images published --- @@ -44,20 +44,16 @@ Expected outcome: ```text Push to main - ↓ -[CI: Build & Test + E2E] - ↓ (tests pass) -[release-please analyzes commits] - ↓ (version bump needed) -[Create/Update Release PR] - ↓ (human reviews & merges) -[Create GitHub Release + Tag (draft)] - ↓ -[Retag CI-built images to semver + latest, sign, attest → publish the release] + ├─→ [release-please analyzes commits → Create/Update Release PR] (release-pr.yml, at once) + └─→ [CI: Build & Test + E2E] (release.yml) + ↓ (tests pass, and this push merged the Release PR) + [Create GitHub Release + Tag (draft)] + ↓ + [Retag CI-built images to semver + latest, sign, attest → publish the release] ``` -> The next Release PR is opened/refreshed by a **second release-please pass** that runs -> *after* a release is published, so it never sees the still-draft release. See +> Right after a release, the next Release PR is opened by a **second release-please pass** that +> runs *after* the release is published, so it never sees the still-draft release. See > [What Happens When You Push](#what-happens-when-you-push). ### What Happens When You Push @@ -70,15 +66,20 @@ Push to main - Unit tests: `task test` (with the coverage ratchet) - E2E tests: the Ginkgo suite in a k3d cluster, plus the project image scan -2. **Release handling** (if tests pass) — release-please runs as **two separate passes**: - - **Cut pass** (`release-please` job): if the previous Release PR was merged, cut its - GitHub Release (created as a *draft* — see step 4). This pass uses - `skip-github-pull-request`, so it does **not** open the next PR. - - **PR pass** (`release-please-pr` job): analyze commits since the last release, determine - the version bump, and create/update the next Release PR (`skip-github-release`). It runs - **after** the release is published, so it sees the just-published release instead of the - draft. Without this split, the run that cut a draft would open a PR computed against the - whole history and propose a bogus "release everything" version. +2. **Release handling** — release-please runs as **separate passes**: + - **PR pass, at once** (`.github/workflows/release-pr.yml`): analyze commits since the last + release, determine the version bump, and create/update the Release PR + (`skip-github-release`). It does not wait for CI: it only rewrites the PR, and the release + itself still waits for CI on the commit that merges it. It is skipped while a draft + release exists, and release-please aborts it on its own while a merged Release PR is + not yet tagged. + - **Cut pass** (`release-please` job in `release.yml`, if tests pass): if the previous + Release PR was merged, cut its GitHub Release (created as a *draft* — see step 4). This + pass uses `skip-github-pull-request`, so it does **not** open the next PR. + - **PR pass after a release** (`release-please-pr` job in `release.yml`): open the next + Release PR **after** the release is published, so it sees the just-published release + instead of the draft. Without this split, the run that cut a draft would open a PR + computed against the whole history and propose a bogus "release everything" version. 3. **Release PR Contents**: - Auto-generated CHANGELOG.md updates diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml new file mode 100644 index 000000000..4ad9fe58c --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,65 @@ +# Refresh the open release PR as soon as a commit lands on main, instead of after release.yml's +# CI (about 30 minutes, longer when an earlier push's run is still queued in front of it). +# +# This is safe to run before the tests because it only rewrites the release PR. Nothing is +# tagged or published here (skip-github-release). A release is still cut only by release.yml, +# after CI has passed on the commit that merges the release PR, and that commit's tree holds +# every change the release contains. +# +# It is a workflow of its own because release.yml's workflow-level concurrency group queues a +# whole run behind the run before it: a job there would wait just as long. +# +# Two states in which the PR pass must not compute a PR, and what covers each: +# - The release PR is merged but release.yml has not cut its release yet. release-please sees +# the merged, untagged release PR and aborts on its own ("untagged, merged release PRs +# outstanding"). +# - The release is cut but still a draft (release.yml attaches its assets before publishing). +# A draft is invisible to release-please's "latest release" lookup, so it would compute the +# PR against the whole history. The check below skips the pass while any draft exists; +# release.yml's release-please-pr job opens the next PR once publish-release has run. +name: Release PR + +on: + push: + branches: [main] + +permissions: + contents: read + +# Shared with release.yml's release-please-pr job, so the two never rewrite the PR branch at +# the same time. A newer push replaces a pending run; a running one finishes first. +concurrency: + group: release-pr-${{ github.ref }} + cancel-in-progress: false + +jobs: + release-please-pr: + name: Release Please (PR) + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + # contents: write also makes draft releases visible to the check below; a read-only + # token lists only published releases. + contents: write + pull-requests: write + steps: + - name: Check for an unpublished draft release + id: draft + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + drafts=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 20 --json tagName,isDraft \ + --jq '[.[] | select(.isDraft) | .tagName] | join(" ")') + if [[ -n "$drafts" ]]; then + echo "::notice::Draft release(s) not yet published: $drafts. release.yml refreshes the release PR after publish-release." + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + + - name: Open or refresh the release PR + if: steps.draft.outputs.skip != 'true' + uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + skip-github-release: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f08cadd1b..a60adb27e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,8 +66,9 @@ jobs: # publish-helm), and a draft is invisible to release-please's own # "latest release" lookup. Opening the next PR in this same invocation # would compute it against the whole history and propose a bogus - # "release everything" version. The release-please-pr job opens/refreshes - # the PR *after* publish-release makes the release visible. + # "release everything" version. The release-please-pr job opens the next + # PR *after* publish-release makes the release visible; release-pr.yml + # refreshes it on every other push, without waiting for CI. skip-github-pull-request: true # Merge the per-arch release-grade digests — already built and pushed by the @@ -523,23 +524,21 @@ jobs: # created. skip-github-release: true means it only opens/refreshes the next # release PR — it never tags or releases. # - # Gating: this must run AFTER publish-release when a release was cut (so the - # release is published and visible), but also on ordinary pushes that cut no - # release (so the pending release PR keeps tracking new commits). The `if` - # therefore runs whenever CI + release-please succeeded and EITHER no release - # was created (publish-release was skipped) OR publish-release succeeded — so a - # failed publish (release still a draft) will NOT open a PR against the draft. + # Gating: only after a release was published. On every other push, + # release-pr.yml has already refreshed the release PR without waiting for CI. + # That workflow skips while a draft exists, so this job opens the next PR. + # A failed publish leaves the draft in place, so this job skips too and no PR + # is computed against the draft. release-please-pr: name: Release Please (PR) runs-on: ubuntu-latest timeout-minutes: 10 - needs: [ci, release-please, publish-release] - if: >- - ${{ !cancelled() - && needs.ci.result == 'success' - && needs.release-please.result == 'success' - && (needs.publish-release.result == 'success' - || needs.release-please.outputs.release_created != 'true') }} + needs: [publish-release] + if: needs.publish-release.result == 'success' + # Shared with release-pr.yml, so the two never rewrite the PR branch at once. + concurrency: + group: release-pr-${{ github.ref }} + cancel-in-progress: false permissions: contents: write pull-requests: write diff --git a/docs/ci-overview.md b/docs/ci-overview.md index 21f6e4735..e25464e26 100644 --- a/docs/ci-overview.md +++ b/docs/ci-overview.md @@ -16,6 +16,7 @@ so local and CI results cannot drift apart. | --- | --- | --- | --- | --- | --- | | Untrusted validation | [ci.yml](../.github/workflows/ci.yml) | `pull_request` | none | none | Full contributor validation: containers, lint, unit, e2e, image scan | | Trusted validation | [release.yml](../.github/workflows/release.yml) → calls `ci.yml` | `push` to `main` | `GITHUB_TOKEN` | `packages` (CI base container + release-grade image digests) | The *same* jobs, plus building the release-grade multi-arch digests (per-arch, by digest) the release tail retags. The instrumented test image stays artifact-only. | +| Release PR | [release-pr.yml](../.github/workflows/release-pr.yml) | `push` to `main`, without waiting for CI | `GITHUB_TOKEN` | the release PR's branch and body | Refresh the open release PR at once; never tags or releases | | Release & publish | [release.yml](../.github/workflows/release.yml) tail jobs | after trusted validation is green | `GITHUB_TOKEN` + OIDC | packages, releases, attestations | Version (release-please), **retag** the CI-built multi-arch digests to semver + `latest` (zero rebuilds), publish chart, sign, attest | | Hygiene | [scorecard.yml](../.github/workflows/scorecard.yml) | weekly + `main` | none | security-events | OpenSSF Scorecard supply-chain checks | | Hygiene | [pr-title.yml](../.github/workflows/pr-title.yml) | `pull_request` (including `edited`) | none | none | The PR title is a Conventional Commit, since it becomes the squash-merge subject release-please classifies | @@ -41,9 +42,16 @@ Three properties are worth calling out: invocation that cut the draft also opened the *next* release PR, that PR would be computed against the whole history and propose a bogus "release everything" version. `release.yml` therefore splits the action: the `release-please` job cuts the release with - `skip-github-pull-request`, and a separate `release-please-pr` job opens/refreshes the next - PR with `skip-github-release` **after** `publish-release` has made the release public and - visible. Same guarantee, correct changelog. + `skip-github-pull-request`, and a separate `release-please-pr` job opens the next PR with + `skip-github-release` **after** `publish-release` has made the release public and visible. + Same guarantee, correct changelog. +- **The release PR is refreshed without waiting for CI.** On every other push, + [release-pr.yml](../.github/workflows/release-pr.yml) runs the PR pass at once, so the PR + lists a merged change minutes after the merge instead of after `release.yml`'s CI. This does + not weaken the guarantee above: the release is still cut only after CI passes on the commit + that merges the release PR, and that commit holds every change the release contains. The + pass is skipped while a draft release exists. release-please aborts on its own while a merged + release PR is untagged. ## How fork PRs work @@ -155,6 +163,6 @@ GitHub's OIDC identity and logged in the public Rekor transparency log. | What gets checked (lint, unit, e2e, packaging) | [Taskfile-build.yml](../Taskfile-build.yml), [test/e2e/Taskfile-e2e.yml](../test/e2e/Taskfile-e2e.yml) — see [tasks-overview.md](tasks-overview.md) | | Tool versions | [.devcontainer/Dockerfile](../.devcontainer/Dockerfile) (single source for devcontainer *and* CI) | | Validation pipeline | [.github/workflows/ci.yml](../.github/workflows/ci.yml) | -| Release pipeline | [.github/workflows/release.yml](../.github/workflows/release.yml) | +| Release pipeline | [.github/workflows/release.yml](../.github/workflows/release.yml), [.github/workflows/release-pr.yml](../.github/workflows/release-pr.yml) | | Hygiene | [.github/workflows/scorecard.yml](../.github/workflows/scorecard.yml), [.github/dependabot.yml](../.github/dependabot.yml) | | Release process details | [.github/RELEASES.md](../.github/RELEASES.md) | From 552e35cceb5d96315d5319ac2dc9e93598ba9573 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Mon, 26 Oct 2026 11:48:09 +0000 Subject: [PATCH 2/2] ci: act on the review of the release PR workflow The troubleshooting checklist for a missing Release PR listed passing CI, which the PR pass no longer waits for; it now points at the Release PR workflow's run and the two states in which it stays quiet. Two em dashes the change added to RELEASES.md are gone. The draft check keeps its limit of 20 releases, with a comment saying why: the only draft that misleads release-please is the one release.yml has just cut, which is the newest release, and an older draft would only block every refresh. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/RELEASES.md | 9 ++++++--- .github/workflows/release-pr.yml | 7 +++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/RELEASES.md b/.github/RELEASES.md index b6520f3c1..d44535926 100644 --- a/.github/RELEASES.md +++ b/.github/RELEASES.md @@ -66,7 +66,7 @@ Push to main - Unit tests: `task test` (with the coverage ratchet) - E2E tests: the Ginkgo suite in a k3d cluster, plus the project image scan -2. **Release handling** — release-please runs as **separate passes**: +2. **Release handling**: release-please runs as **separate passes**: - **PR pass, at once** (`.github/workflows/release-pr.yml`): analyze commits since the last release, determine the version bump, and create/update the Release PR (`skip-github-release`). It does not wait for CI: it only rewrites the PR, and the release @@ -74,7 +74,7 @@ Push to main release exists, and release-please aborts it on its own while a merged Release PR is not yet tagged. - **Cut pass** (`release-please` job in `release.yml`, if tests pass): if the previous - Release PR was merged, cut its GitHub Release (created as a *draft* — see step 4). This + Release PR was merged, cut its GitHub Release (created as a *draft*, see step 4). This pass uses `skip-github-pull-request`, so it does **not** open the next PR. - **PR pass after a release** (`release-please-pr` job in `release.yml`): open the next Release PR **after** the release is published, so it sees the just-published release @@ -173,7 +173,10 @@ See [Prerequisites Setup](#prerequisites-setup) above. **Check:** -- ✅ CI tests passed (green checkmark in Actions tab) +- ✅ The `Release PR` workflow (`release-pr.yml`) ran for the push and its PR step did not skip. + It skips while a draft release exists (see its run notice), and release-please logs + "untagged, merged release PRs outstanding" while a merged Release PR waits for its release. + Passing CI is not needed: this pass does not wait for it. - ✅ Commit used conventional format (`feat:`, `fix:`, etc.) - ✅ Pushed to `main` branch - ✅ GitHub Actions has PR creation permissions diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 4ad9fe58c..e697761d1 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -15,8 +15,8 @@ # outstanding"). # - The release is cut but still a draft (release.yml attaches its assets before publishing). # A draft is invisible to release-please's "latest release" lookup, so it would compute the -# PR against the whole history. The check below skips the pass while any draft exists; -# release.yml's release-please-pr job opens the next PR once publish-release has run. +# PR against the whole history. The check below skips the pass while a recent release is a +# draft; release.yml's release-please-pr job opens the next PR once publish-release has run. name: Release PR on: @@ -48,6 +48,9 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | + # Only the draft release.yml has just cut misleads release-please, and it is the + # newest release, so the newest 20 always include it. Not paginated on purpose: a + # draft older than those cannot be that one, and would only block every refresh. drafts=$(gh release list --repo "$GITHUB_REPOSITORY" --limit 20 --json tagName,isDraft \ --jq '[.[] | select(.isDraft) | .tagName] | join(" ")') if [[ -n "$drafts" ]]; then