Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 29 additions & 25 deletions .github/RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---

Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions .github/workflows/release-pr.yml
Original file line number Diff line number Diff line change
@@ -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'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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
29 changes: 14 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions docs/ci-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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) |
Loading