Skip to content

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):

  1. 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-token permissions.
  2. Tag rules — restrict v*.*.* creation to protected main / allowed actors so arbitrary commits cannot be tagged into the release path.

L4 pre-release gate

Before tagging, require one of:

  1. Green e2e-nightly workflow run on ${RELEASE_SHA} (re-run via workflow_dispatch if the scheduled cron has not yet picked up the commit), or
  2. Manual test-e2e workflow dispatch on that SHA, or
  3. Local task test:e2e on 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.