Releasing a new version¶
Step-by-step guide for maintainers publishing a Kollect release.
Related: CONTRIBUTING.md (commits), development/setup.md (local tasks), ROADMAP.md (feature status).
Overview¶
Releases are tag-driven: push a tag vX.Y.Z on main and
.github/workflows/release.yaml builds, scans, signs, and
publishes artifacts. Version numbers are not bumped by CI: land the release prep (the chart
version, the chart README and the release-truth pages, steps 3 to 5 below) on main first.
CHANGELOG.md is not part of it: the changelog-sync workflow writes the ## [X.Y.Z] section
after the tag exists.
While the API is v1alpha1, use minor (0.3.0, 0.4.0, …) for themed feature tranches or
breaking operator behaviour; patch (0.2.1) for fixes on the current minor line. Breaking
commits use ! in the subject (see CONTRIBUTING.md).
Versioning policy¶
Kollect uses frequent pre-1.0 minors. The current release is shown in GitHub Releases; the authoritative history is the changelog.
| Policy | Detail |
|---|---|
| Cadence | Release when a coherent, validated change set is ready; no fixed calendar |
| RC tags | vX.Y.Z-rc.N — soak on green main; use workflow_dispatch with draft + prerelease |
| Breaking changes | feat!: / BREAKING CHANGE: → minor bump pre-v1.0 |
| Compatibility | Patch releases stay compatible with their minor line; breaking changes require a minor |
Before 1.0, a minor release may include breaking API or default changes. Mark each one with ! in
the title of the commit that makes it (feat(sink)!: …), with a BREAKING CHANGE: footer in that
commit's body. Pull requests land by rebase merge, so git-cliff reads the commits and never the PR: a
! in a PR title or a BREAKING CHANGE: in a PR body never reaches the changelog. On a commit,
git-cliff renders only the title: a footer flags the entry [**breaking**] but its text is dropped,
so the project rule is the ! on the title, with the footer in that commit's body. The breaking-marker check fails a PR that declares a breaking change none
of its commit titles carries. Write the migration steps in
docs/operator-manual/upgrading.md and in the upgrade-notes block
you prepend by hand to the GitHub Release (see Cut a release), not in the
changelog. Patch releases stay compatible with their minor line.
Retroactive version anchors¶
History before the first GitHub release is split with lightweight tags (changelog anchors only):
| Tag | Commit | Milestone |
|---|---|---|
v0.0.1 |
13546aff |
Kubebuilder scaffold |
v0.0.2 |
1e6f6719 |
Core v1alpha1 CRDs |
v0.0.3 |
66421337 |
Helm chart, extraction, inventory HTTP |
v0.0.4 |
4234960b |
ADR-0201 platform pivot MVP |
v0.1.0-rc.1 – rc.3 |
2026-06-05 – 06 | Pre-strategy RCs (finalizers, helm, e2e, release pipeline) |
v0.2.0-rc.1 |
2026-06-07 | Sink-family tranche |
Push changelog anchor tags once (if not already on the remote):
git tag v0.0.1 13546aff
git tag v0.0.2 1e6f6719
git tag v0.0.3 66421337
git tag v0.0.4 4234960b
git push origin v0.0.1 v0.0.2 v0.0.3 v0.0.4
Pre-release checklist¶
git checkout main && git pull
RELEASE_SHA="$(git rev-parse HEAD)"
echo "Tagging: ${RELEASE_SHA}"
task verify
task lint
task test
task helm-test
task changelog:verify
Ensure CI, preflight, and kind-smoke (e2e-smoke.yaml) are green on ${RELEASE_SHA}
on GitHub Actions. Docs-only or path-filtered commits that skipped a required job are not
eligible — re-dispatch the skipped workflow on that exact SHA.
Then run the read-only Release gate against that immutable SHA. It rejects commits not reachable
from protected main, missing/cancelled/unsuccessful required checks, and commits that are not the
merge commit of a merged-to-main PR. Non-author APPROVE is not required (solo-maintainer
policy; Environment release + tag ruleset still gate publication):
gh workflow run release-gate.yaml -f sha="${RELEASE_SHA}"
gh run list --workflow release-gate.yaml --limit 1
The publishing workflow independently repeats this eligibility check before registry login, signing, attestations, or release uploads. Gate scripts are always loaded from the default branch so a candidate tag cannot supply its own verifier. Publication checks out the proven immutable SHA (not a movable tag ref alone) and refuses to continue if the tag no longer resolves to that SHA.
Operator protection (required)¶
Configure these in the GitHub repo settings (not expressible in workflow YAML alone):
- Environment
release— required reviewers (and optionally a wait timer) on the write-capable Release job. Without this, a malicious tagged workflow copy that drops the eligibility job could still obtain write/id-tokenpermissions. - Tag rules — restrict
v*.*.*creation to protectedmain/ allowed actors so arbitrary commits cannot be tagged into the release path.
L4 pre-release gate¶
Before tagging, require one of:
- Green
e2e-nightlyworkflow run on${RELEASE_SHA}(re-run viaworkflow_dispatchif the scheduled cron has not yet picked up the commit), or - Manual
test-e2eworkflow dispatch on that SHA, or - Local
task test:e2eon the release commit (document run ID / timestamp in the release notes).
L3 integration (test-integration in CI) remains the merge gate for sink backends; nightly L4
no longer duplicates export-integration or object-store jobs.
Git export test repository (optional)¶
For full remote git SHA assert in e2e-nightly, e2e-extended, and test-e2e
git-export jobs, set repository variable GIT_EXPORT_TEST_REPO in GitHub → Settings →
Actions → Variables (clone URL of a dedicated test repo). Workflows pass ${{ vars.GIT_EXPORT_TEST_REPO }}
with GITHUB_TOKEN; this cannot be set from workflow YAML. Without the variable, git-export jobs
verify inventory HTTP hash only (degraded mode).
RC pre-release on GitHub Actions¶
The release workflow accepts draft and prerelease inputs only on workflow_dispatch.
Pushing a tag matching v*.*.* triggers a non-draft release automatically — use rc tags with
dispatch inputs when you need draft/prerelease metadata.
Steps (maintainer, on green main):
git checkout main && git pull
RELEASE_SHA="$(git rev-parse HEAD)"
git tag v0.3.0-rc.1 "${RELEASE_SHA}"
git push origin v0.3.0-rc.1
Then trigger a draft pre-release rebuild if needed:
gh workflow run release.yaml \
-f tag=v0.3.0-rc.1 \
-f draft=true \
-f prerelease=true
Monitor: gh run list --workflow=release.yaml --limit 3
Skip tag push if you only want local validation — task release-dry-run covers assets without
publishing to GHCR or GitHub Releases.
Version and changelog¶
1. Preview unreleased notes¶
task changelog
2. Choose the version¶
| Change | Example bump |
|---|---|
| Themed feature tranche / breaking operator behaviour | 0.2.0 → 0.3.0 |
| Bug fixes on current minor | 0.2.0 → 0.2.1 |
| Soak before minor GA | Tag 0.3.0-rc.1 first |
3. Bump the Helm chart¶
Edit charts/kollect/Chart.yaml:
# kollect-doc: ignore Helm Chart.yaml, not a kollect CR
version: 0.3.0
appVersion: "0.3.0"
Align version and appVersion with the git tag (v0.3.0 → 0.3.0), then regenerate the
chart README, whose version badges come from Chart.yaml:
task helm-docs # rewrites charts/kollect/README.md; task helm-test fails while it is stale
Also bump the artifacthub.io/images annotation in the same file — it is a hardcoded tag,
not derived from appVersion:
# kollect-doc: ignore Helm Chart.yaml annotations, not a kollect CR
annotations:
artifacthub.io/images: |
- name: kollect
image: ghcr.io/platformrelay/kollect:v0.3.0
An explicit artifacthub.io/images list overrides Artifact Hub's automatic image extraction,
so a stale tag makes the listing advertise (and security-scan) the previous release's image.
hack/test/dist_artifacthub_chart_test.sh fails the build if the tag does not equal
v<appVersion>, so CI catches a missed bump — but fix it here, not in CI.
4. Update the release-truth pages¶
In the same commit, move every page that names the current release to the new version, and check each claim against what actually ships, not just the number:
| Page | What to update |
|---|---|
docs/ROADMAP.md |
**Last verified:** date and version, the ## Shipped in vX.Y.Z heading, items new in this release, and breaking upgrade steps with links |
docs/roadmap/planned-features.md |
**Last verified:** line; remove anything that has now shipped |
overrides/main.html |
Announcement bar link and version |
docs/operator-manual/upgrading.md |
Migration steps for every breaking change |
docs/getting-started/install.md |
The --version pin on the chart install command |
hack/test/docs_launch_truth_test.sh (part of task docs:verify) checks these against the
Chart.yaml version, so the Docs workflow fails the release-prep PR, not a later unrelated one,
when a page is left behind. Run task docs:verify before opening the PR.
5. Commit the release prep¶
git add charts/kollect/Chart.yaml charts/kollect/README.md docs/ROADMAP.md \
docs/roadmap/planned-features.md overrides/main.html docs/operator-manual/upgrading.md \
docs/getting-started/install.md
git commit -m ":bookmark: chore(release): prepare v0.3.0"
The prep commit is exactly these files: the chart, its README and the release-truth pages. Do not
add a ## [X.Y.Z] heading to CHANGELOG.md by hand, and do not run task changelog:write for it
either: before the tag exists git-cliff can only render [Unreleased], so that task never cuts a
version section. The changelog-sync workflow writes the ## [X.Y.Z] section on the first push to
main after the tag (a tag push alone does not trigger it). Preview the notes with
task changelog (step 1).
Between merging this PR and pushing the tag, the deployed docs banner links to a release page that does not exist yet. Keep that window short, and revert the prep PR if the release is abandoned.
Cut a release¶
Land the release prep through a protected-main PR (rebase-merge). A second human review is not
required for solo-maintainer releases. Refetch and record the exact resulting main SHA:
git fetch origin main
git switch main
git pull --ff-only origin main
RELEASE_SHA="$(git rev-parse HEAD)"
gh workflow run release-gate.yaml -f sha="${RELEASE_SHA}"
Only after that gate succeeds on the same immutable SHA:
git tag v0.3.0 "${RELEASE_SHA}"
git push origin v0.3.0 # triggers release workflow, which repeats eligibility before login
Do not git push origin main outside branch protection, and do not move an existing release tag.
CI publishes the GitHub Release, GHCR image, OCI Helm chart, and attached assets. The run waits for
two approvals on the release environment, one per job that uses it: first the release job
(publication), then the OperatorHub PR job (the upstream submissions). Approve each from the run
page (Actions → Release → Review deployments); the second prompt appears only after the
release job succeeds, and only on a tag push (a workflow_dispatch rebuild skips the OperatorHub
job).
Prepend the upgrade notes once the release is published. The GitHub Release body is git-cliff's
section plus the install footer, and git-cliff renders only commit titles: a breaking commit shows
as [**breaking**] with no migration text, even when its body has a BREAKING CHANGE: footer. Put a hand-written "Upgrade notes" block at the top, one
entry per breaking change with a link to its section in
docs/operator-manual/upgrading.md:
gh release view v0.3.0 --json body --jq .body >/tmp/release-body.md
cat upgrade-notes.md /tmp/release-body.md >/tmp/release-body-new.md
gh release edit v0.3.0 --notes-file /tmp/release-body-new.md
Dry-run locally before tagging:
VERSION=0.3.0 task release-dry-run
ls -la dist/
Rebuild assets for an existing tag: Actions → Release → Run workflow → enter the tag
(optional draft / prerelease inputs). Rebuilds still require eligibility on the tag's commit SHA
and the protected release environment.
What CI publishes¶
| Output | Location |
|---|---|
| Container image (operator) | ghcr.io/platformrelay/kollect:v<version> only, linux/amd64 + arm64 (bare :<version> tags on this repo path are historical Helm charts pushed before ADR-0709 — DR-FIND-07; they are inert but permanent) |
| Container image (pipeline CLI) | ghcr.io/platformrelay/kollect-pipeline:<version> (and :v<version>), linux/amd64 + arm64 |
| OCI SBOM + SLSA provenance | GHCR attestations on operator and pipeline images |
| GitHub Release | git-cliff section + install footer; assets below |
install-crds.yaml |
CRD bundle |
install.yaml |
Full operator install (image pinned to tag) |
kollect-<version>.tgz |
Helm chart tarball |
sbom.spdx.json |
SPDX SBOM for operator image (Syft) |
sbom-pipeline.spdx.json |
SPDX SBOM for kollect-pipeline image (Syft) |
checksums.txt |
SHA256 of release files |
<asset>.sigstore.json |
Sigstore bundle for each release asset (cosign keyless) |
release-provenance.intoto.jsonl |
Combined SLSA provenance attestation for release assets |
| Helm chart (OCI) | oci://ghcr.io/platformrelay/charts/kollect (ADR-0709 — the chart path is separate from the image path; one Artifact Hub repository entry indexes one chart) |
Release notes are assembled by hack/assemble-release-notes.sh
and .github/release-notes-install.md.
Submit to OperatorHub (manual fallback)¶
The operatorhub-pr job in the release workflow opens the two upstream PRs automatically, but
it skips silently when OPERATORHUB_PAT is not configured — it logs
OPERATORHUB_PAT not configured; skipping OperatorHub submission. and the job still goes green.
Check the job log after a release; if it skipped, submit by hand:
TAG=v0.18.0
REPO=platformrelay # the OWNER; the image name is appended below
# DR-FIND-07 — READ THIS BEFORE RESOLVING THE DIGEST.
# ghcr.io/platformrelay/kollect holds TWO artifact kinds:
# ghcr.io/platformrelay/kollect:0.18.0 -> the HELM CHART
# ghcr.io/platformrelay/kollect:v0.18.0 -> the CONTROLLER IMAGE <- you want this one
# New charts publish to ghcr.io/platformrelay/charts/kollect (ADR-0709), but the bare tags
# already on this path stay forever, so this hazard is permanent for released versions.
# Both give you a valid sha256 digest, so a mistake here passes every string check and
# only surfaces ~30 minutes into the upstream pipeline as a DeployableByOLM timeout
# (CreateContainerError "image not known"). Always digest the V-PREFIXED tag.
IMAGE_DIGEST="$(crane digest "ghcr.io/${REPO}/kollect:${TAG}")"
# No crane? docker buildx imagetools inspect "ghcr.io/${REPO}/kollect:${TAG}" \
# --format '{{.Manifest.Digest}}'
VERSION="${TAG#v}" IMAGE_DIGEST="${IMAGE_DIGEST}" GH_TOKEN=<pat> hack/operatorhub-pr.sh
hack/operatorhub-pr.sh refuses to submit a digest that is not a runnable container image
(hack/lib/olm-image-digest.sh), so the chart digest is rejected before anything reaches a
third-party repository.
Before it branches or pushes, the script brings each fork's default branch up to date with
upstream main. It reads both forks first, without writing: each must be a fork of its upstream,
and its default branch must be identical to or behind upstream. A fork that is behind is then
fast-forwarded through GitHub's merge-upstream API (what gh repo sync calls) and compared
again. The script never force-pushes a fork's default branch and only syncs one that is strictly
behind. If either fork is ahead or has diverged, it stops before writing anything, with a message
like:
ERROR: fork platformrelay/community-operators:main is diverged relative to k8s-operatorhub/community-operators:main (ahead by 2, behind by 5).
Look at the fork's extra commits (gh api repos/k8s-operatorhub/community-operators/compare/main...platformrelay:community-operators:main).
If nothing on the fork's main needs keeping, reset it to upstream from a clone of the fork
(git fetch upstream main && git push --force-with-lease origin upstream/main:main); that is a
deliberate, manual force-push the script will not make. Then re-run the script. The same token
scopes cover the sync: public_repo for the fork write, workflow when the upstream range
touches .github/workflows/.
merge-upstream takes no expected commit, so if someone pushes to a fork's main in the seconds
between the check and the sync, GitHub can write a merge commit there. The script then reports
merge type 'merge', not a fast-forward and stops before pushing; inspect the fork as above.
DRY_RUN=1 generates and validates the bundle and makes no remote writes. With GH_TOKEN set it
also reads both forks (GET requests only) and prints, for each one, the sync it would make, the
branch it would push and the PR it would open, and it fails on a diverged fork just as a real run
would:
DRY_RUN=1: would sync platformrelay/community-operators:main from k8s-operatorhub/community-operators:main (behind by 15) via POST repos/platformrelay/community-operators/merge-upstream
DRY_RUN=1: would create branch kollect-v0.21.0 from k8s-operatorhub/community-operators:main with dist/olm-bundle/0.21.0 and push it to platformrelay/community-operators (git push --force origin kollect-v0.21.0)
DRY_RUN=1: would open or update the PR platformrelay:kollect-v0.21.0 -> k8s-operatorhub/community-operators:main
Without GH_TOKEN it makes no API call and says the fork state was not checked. Either way it
exits before the runnable-image check — that check needs a registry, and keeping it off the
dry-run path is what lets the offline meta-tests run with a synthetic digest. A clean dry run
therefore says nothing about whether IMAGE_DIGEST points at a real image; only a live
submission verifies that.
To correct an already-open submission, push a new commit to the kollect-v<version> branch on
the platformrelay fork of each upstream repo — that re-triggers the hosted pipelines.
Verify after release¶
Container images (GHCR)¶
TAG=v0.2.0-rc.1 # or your release tag
REPO=platformrelay # the OWNER; the image name is appended below
# DR-FIND-07: the operator image is published at the v-prefixed tag only; the
# bare "${TAG#v}" tag on this repo path is a (historical) Helm chart, so digest by ${TAG}.
# Charts themselves now live at ghcr.io/platformrelay/charts/kollect (ADR-0709).
OP_DIGEST="$(crane digest ghcr.io/${REPO}/kollect:${TAG})"
PIPELINE_DIGEST="$(crane digest ghcr.io/${REPO}/kollect-pipeline:${TAG#v})"
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/[Pp]latform[Rr]elay/[Kk]ollect/.+' \
"ghcr.io/${REPO}/kollect@${OP_DIGEST}"
cosign verify \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/[Pp]latform[Rr]elay/[Kk]ollect/.+' \
"ghcr.io/${REPO}/kollect-pipeline@${PIPELINE_DIGEST}"
SLSA provenance and SPDX SBOM attestations are published to GHCR (via actions/attest) and the
repository Attestations page:
gh attestation verify "ghcr.io/${REPO}/kollect@${OP_DIGEST}" \
--owner platformrelay --repo kollect
gh attestation verify "ghcr.io/${REPO}/kollect-pipeline@${PIPELINE_DIGEST}" \
--owner platformrelay --repo kollect
GitHub Release assets (OpenSSF Scorecard Signed-Releases)¶
Each release asset ships with a Sigstore bundle (<file>.sigstore.json) and a combined SLSA
provenance bundle (release-provenance.intoto.jsonl). Verify a downloaded artifact:
TAG=v0.2.0-rc.1
VERSION="${TAG#v}"
gh release download "${TAG}" --pattern 'kollect-*.tgz' --dir /tmp/kollect-verify
cd /tmp/kollect-verify
cosign verify-blob \
--bundle "kollect-${VERSION}.tgz.sigstore.json" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp '^https://github\.com/[Pp]latform[Rr]elay/[Kk]ollect/.+' \
"kollect-${VERSION}.tgz"
Checksums: sha256sum -c checksums.txt after downloading all unsigned assets.
Rebuild an existing tag with signing¶
gh workflow run release.yaml \
-f tag=v0.2.0-rc.1 \
-f draft=false \
-f prerelease=true
After the next push to main, confirm CHANGELOG.md has the ## [X.Y.Z] section and an empty
Unreleased section. If the changelog-sync run did not push it (its push step soft-fails), land
the output of task changelog:write through a PR; with the tag present it renders the section.