diff --git a/.github/RELEASES.md b/.github/RELEASES.md index 45bb9281..d4453592 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 @@ -172,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 new file mode 100644 index 00000000..e697761d --- /dev/null +++ b/.github/workflows/release-pr.yml @@ -0,0 +1,68 @@ +# 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 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: + 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: | + # 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 + 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 f08cadd1..a60adb27 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 21f6e473..e25464e2 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) |