CI/CD & Workflows¶
All automation lives in .github/workflows/. This page is the source of truth
for what each workflow does, when it runs, and how to invoke it manually. The
in-repo .github/workflows/README.md is a short pointer back here.
| Workflow | File | Triggers |
|---|---|---|
| CI | ci.yml |
push/PR to main/develop, manual dispatch |
| Integration Tests | integration.yml |
PR + push to main on runtime paths |
| Extended Integration Tests | integration-tests.yml |
manual dispatch only (full) |
| Install Confidence | install-confidence.yml |
manual dispatch only (the same matrix also runs inside release.yml) |
| Release | release.yml |
push of a vX.Y.Z tag, manual dispatch |
| Retracting a release | release-retract.yml |
manual dispatch only |
| Pages — Docs | pages-docs.yml |
push to main, manual dispatch, and workflow_call from release.yml (no tag trigger of its own) |
| Pages — Helm Repo | pages-helm.yml |
manual dispatch and workflow_call from release.yml (no tag trigger of its own) |
| CodeQL | codeql.yml |
push/PR to main, weekly schedule |
Shared Go setup/caching lives in the composite action .github/actions/setup-go.
CI¶
ci.yml — runs on every push and PR to main/develop. Fast feedback; the
gate that blocks merge. Jobs:
- Generated Artifacts In Sync (
check-drift) — runsmake check-drift(sync-all+bundle, thengit diff --exit-code). Fails if any committed CRD, RBAC, deepcopy, Helm CRD, or OLM bundle file is stale (untracked generated files fail it too). Fix locally withmake sync-alland commit the result. The same job then runs the static checks that have no generator to catch them:make helm-lint,make check-csv-coverage,make check-apiref-drift,make check-cli-asset-names,make check-release-notes-links,make check-cli-docs,make check-docs-release-pins,make check-examples-catalog,make check-docs-tables,make check-crd-catalog, and a gitleaks secret scan. All are static and seconds-fast; the failure summary distinguishes drift failures from the rest. - Go Lint (
go-lint) — builds golangci-lint against the current toolchain and runs the whole.golangci.yml(ci.ymlcalls it a merge gate), then cross-compileskubectl-neo4jfor every released platform (darwin, linux, windows) so a unix-only import fails here rather than at release time. - Unit Tests (
unit-tests) —make test-unit(race-enabled, envtest-backed controller suite + plain unit tests). No external cluster required. The job runs the suite through gotestsum (make test-unit GO_TEST_CMD="./bin/gotestsum …"), which emits a JUnit XML + test2json report; a summary step (scripts/gotest-summary.sh) writes the failed and slowest tests to the GitHub step summary. - Invariant Guards (advisory) — runs
scripts/check-invariants.shandscripts/check-knowledge-drift.shwithcontinue-on-error, so a violation is reported in the run summary but never fails the job or blocks merge. See Agent guardrails.
Integration coverage lives in its own workflows, not in ci.yml: the fast
contributor lane is Integration Tests; the full matrix is
Extended Integration Tests.
Caching¶
The workflows cache the expensive, slowly-changing inputs so reruns stay fast:
./bintools + envtest assets — cached by the.github/actions/setup-gocomposite action, keyed onhashFiles('Makefile')(the tool versions are pinned there), sokustomize/controller-gen/ginkgo/setup-envtestaren't re-downloaded every run.- Go build/module cache (
GOCACHE) — restored viaactions/setup-go's built-in caching, keyed ongo.sum. - Operator image layers — the integration lanes build with Buildx and a
GitHub Actions layer cache (
cache-from/to: type=gha), so unchanged build stages are reused. - Neo4j image tarball — the pulled
neo4j:<tag>-enterpriseimage is saved to/tmp/neo4j-image.tarand restored withactions/cache, avoiding a Docker Hub pull on every integration run.
Branch protection¶
main is a protected branch. The rules enforce the gates above:
- No direct pushes — all changes land via pull request.
- 1 approval, from a CODEOWNER; stale approvals are dismissed on new commits; review threads must be resolved.
- Required status checks (strict / branch must be up to date), matching the CI job display names exactly:
Generated Artifacts In SyncUnit Tests- Linear history (squash/rebase merges); force-pushes and deletion blocked.
Two things to know:
- The integration checks are intentionally not required. The Integration Tests lane is path-filtered (it doesn't run on, e.g., a docs-only change) and the Extended Integration Tests workflow is manual-dispatch only — so neither runs on every PR; a required check that doesn't run on a given PR would leave it stuck at "Expected — waiting for status." Reviewers gate on them manually for code changes.
enforce_adminsis currently off so the maintainer can merge during the solo→team transition (GitHub forbids approving your own PR, and the sole CODEOWNER would otherwise be unable to merge anything). Turn it on once a second reviewer is onboarded:
If you rename a required CI job in ci.yml, update the protection contexts to
match or every merge will block.
Test tiers: core vs extended¶
Every integration spec carries a Ginkgo label on its top-level Describe:
core— reconcile contracts a routine change is most likely to break: standalone → Ready, cluster formation,Neo4jDatabase/Neo4jUser/Neo4jRole/Neo4jRoleBindingCRUD, config rendering, plugin install, basic TLS. Small, fast, deterministic on both tracks.extended— multi-node scaling, split-brain, rolling upgrade, the full backup/restore matrix, property sharding, MinIO/cloud, MCP, ABAC/OIDC. Slow, resource-heavy, or version-gated; high value before a release, low marginal value per PR.
The two workflows below select by label. Run a tier locally with
ginkgo run --label-filter='core' ./test/integration/... (or 'extended', or
'core || extended' for everything). When you add a spec file, label its
Describe or it runs in neither lane.
Integration Tests¶
integration.yml — the fast contributor lane. Runs the core subset
against both supported Neo4j tracks in parallel:
5.26-enterprise— the last SemVer LTS; exercises the SemVer-only operator paths (V2_ONLY discovery,system_bootstrapping_strategy).- the pinned CalVer tag (currently
2026.08.1-enterprise) — the track new users deploy; catches strict-mode fatals (duplicate conf keys, Cypher-25 defaults) that 5.26 tolerates.
Because it's the core subset and the two cells run in parallel, wall-clock ≈ the
slower (CalVer) cell, not the sum. Triggers on pull_request + push to main
when runtime paths change (internal/**, api/**, cmd/**,
test/integration/**, config/**, Dockerfile, Makefile, go.{mod,sum}, the
workflow itself) — never on docs-only changes.
This is the lane that should give a contributor a fast, legible yes/no on the contracts they touched, on the versions users actually run.
Caveat — a new push cancels the in-flight run. Each integration lane uses a per-PR concurrency group with
cancel-in-progress: true, keyed on the PR number (integration-core-${{ github.event.pull_request.number || github.run_id }}and the Extended equivalent). Pushing a new commit while a run is still going terminates that run and starts a fresh one on the new head — even a docs-only or trivial push (onpull_requestthe path filter is evaluated against the cumulative base→head diff, so any push re-fires the lane if the PR has ever touched a runtime path). The cancelled run shows up as red/"cancelled", not a failure. If you're waiting on a green integration result, let it finish before pushing again, or batch your changes into one push.
Extended Integration Tests¶
integration-tests.yml — the full suite (core + extended, ≈90–150 min)
against a real Kind cluster, on the pinned CalVer track. This is the
release-readiness and deep-coverage run.
Extended never runs automatically — by design, the default PR/push signal is
the fast core lane, keeping the dev cycle short. The extended suite is
manual-dispatch only; there is no nightly schedule and no PR-label trigger
(both were intentionally removed). Run it on demand from the Actions tab
("Run workflow") or with gh workflow run integration-tests.yml --ref <branch>,
full suite, with inputs:
neo4j-version— the exact image tag, used verbatim asneo4j:<tag>(default the pinned CalVer, which already satisfies the CalVer-gated specs, property sharding included; pass5.26-enterpriseto verify the LTS floor). Dispatch against your branch to run the full suite before merging.timeout-minutes— default150(CalVer is ~2× slower per spec).
Dispatch it explicitly for backup/restore/sharding/coordination changes that the
core subset doesn't exercise, and on the release commit before tagging (see the
release process below).
It builds and deploys the operator, runs the selected scope, uploads logs/cluster-state artifacts, and tears the cluster down.
Not everything labelled
extendedruns in this lane. The tier label only selects the lane; a runtimeSkipcan still exclude a spec there. The resource-heavy property-sharding suites self-skip in CI (they need the production 4Gi/server floor) and are local-only — only the minimalproperty_sharding_ci_smokeruns here. See Testing → What runs where for the full map and how to run the local-only suites.
Bumping the CalVer pin: the version is pinned (not floating) for
deterministic CI, in two places that must move together (GitHub allows
neither an env var in a matrix nor an expression in a dispatch-input default,
so there's no single shared variable):
- the
neo4j-versionmatrix list inintegration.yml(the core lane), and - the
neo4j-versioninput default inintegration-tests.yml(the Extended lane).
Bump both in the same PR — the bump is itself a tested change.
Neo4j version support: validation gates and the next LTS¶
The user-facing policy is Supported Neo4j Versions. What enforces it:
- Hard-reject (version validator): anything older than the current LTS (pre-5.26 today) or a line past its Neo4j EOL.
- Allow, don't block "newer than the validated anchor" within a supported track — a brand-new CalVer must not be rejected the day it ships.
- CI anchors: integration suites run
5.26-enterprise+ the latest CalVer. Invariant: exactly two anchors steady-state; a transition window may run three.
When the next LTS lands, the change is small and contained — touch:
- the version validator's allowed/minimum set (
internal/validation/), - the CI matrix anchors (
.github/workflows/integration.yml,integration-tests.yml), - the "Supported Neo4j versions" line in
CLAUDE.md, - the matrix at the top of Supported Neo4j Versions.
At the old LTS's EOL, raising the floor also lets you delete its now-dead version gates (e.g. the SemVer-only discovery paths once 5.26 is dropped).
Install Confidence¶
scripts/install-confidence.sh — the install/upgrade/uninstall matrix on a
throwaway Kind cluster. Run locally with make install-confidence (~10–15
min; needs kind, helm, kubectl, docker), or dispatch
install-confidence.yml manually. It builds the operator image from the
working tree, loads it into a fresh Kind cluster, and walks five legs:
- Helm install, cluster mode — default values, then a smoke
Neo4jEnterpriseStandaloneCR proving the operator actually reconciles (StatefulSet appears). - Helm install, namespaces mode +
rbac.perNamespaceRoles— asserts the manager permissions land as a namespacedRolein the watched namespace and NO manager ClusterRole is created. - Helm upgrade from the previously published chart — installs the latest
released chart, applies the new CRDs with
kubectl apply --server-side -f config/crd/bases/(the step real users must run — Helm never upgradescrds/), upgrades to the working-tree chart, and probes a new-in-this-release CRD field to prove the refresh took.PREV_CHART_VERSION=<x.y.z>pins the starting chart (empty = latest published). - Documented-order uninstall with a live CR — CRs first (finalizers need
the running operator), then
helm uninstall, then CRDs; asserts nothing wedges. - kubectl server-side apply path — install, re-apply (idempotence), and ordered teardown of the non-Helm manifests.
The same matrix runs as a blocking job in release.yml — a release tag
cannot publish an image or chart if any leg fails. Use the local run (or the
dispatch workflow) to catch problems before tagging instead of failing the tag.
Output contract (#247): every sub-step prints a [HH:MM:SS +elapsed]
prefix and waits pre-announce their timeout (helm --wait 180s/300s,
rollout 180s, smoke poll 60s) — so a watcher can always tell slow from
stuck. The docker build logs to /tmp/ic-build.log (tailed on failure).
On any failure the script dumps cluster diagnostics — pods, the operator
Deployment + its logs, recent events, helm releases — before exiting, and in
Actions a verdict table (every passed check + the failing one) lands in the
step summary on both pass and fail.
Release¶
release.yml — tag-driven release pipeline. Push a vX.Y.Z tag (or dispatch
with a tag input). Jobs:
- determine-tag / validate-release — resolve the tag and run build/test
validation, including
make check-drift(a tag on a commit that bypassed per-PR CI must not ship stale generated artifacts). - install-confidence — the full install/upgrade/uninstall
matrix on Kind, in parallel with validate-release.
Blocking:
build-and-pushdepends on it, so a broken install path fails the release before anything is published. - build-and-push — multi-arch (
linux/amd64,linux/arm64) image toghcr.io/priyolahiri/neo4j-kubernetes-operator, signed with Sigstore Cosign keyless (id-token: writeOIDC — no long-lived secrets). - create-release — assembles the kubectl-applyable bundles
(
neo4j-kubernetes-operator-complete.yaml,…operator.yaml), stamps the OLM CSV viamake bundle-release(with a guard that refuses to publish ifcreatedAt:is still the dev placeholder), renders the release body from.github/release-notes-template.md(its docs links point at the release's ownvX.Ydocs), and publishes the GitHub release.
The release workflow's final jobs (publish-docs, publish-helm) then call
Pages — Docs and Pages — Helm Repo (below) once every gate and artifact
exists, so a single tag publishes images, the release, the docs version, and the
chart.
Cutting a release (runbook)¶
Releases are tag-driven. Do not bump Chart.yaml — version/appVersion
are the 0.0.1 placeholder in the repo and the workflows stamp the real value
from the tag.
Before tagging (on an up-to-date, green main):
make ship-prep— regenerates everything, builds the bundle, and runshelm-lint+check-csv-coverage. Reviewgit statusand commit anything regenerated.- (Recommended)
make install-confidence— the same install/upgrade/uninstall matrix the release workflow runs as a blocking gate. Running it locally first means a failure costs you minutes, not a dead tag. 2b. Dispatch the extended suite on the final main and wait for green:gh workflow run integration-tests.yml --ref main. The extended lane is manual-only — it never runs on its own, so a PR that only core lanes validated can carry a stale extended-lane expectation (or a real regression) that would otherwise surface AFTER the release. This dispatch is the release gate for the extended coverage; tag the exact commit the suite validated. - If there are breaking changes or notable upgrade steps, add/extend the
Upgrading between future releasessection inmigration_guide.md(the Upgrade Guide). 3b. Bump the documented install pins (Helm--version, theRELEASE_VERSIONexamples, the ArgoCDtargetRevision,go install) to the new version onmainbefore pushing the tag —make check-docs-release-pins(run in thecheck-driftjob) lets docs run ahead of the newest tag but fails once the tag exists and the docs still name the previous release.make ship-prepdoes not run it; run it yourself. - Draft the What's Changed notes —
git log <last-tag>..HEAD --pretty=onelineis a good starting point. (The release workflow renders only the boilerplate from.github/release-notes-template.md; the changelog is hand-written.)
Tag and push from main:
This fires Release only. The versioned docs (/vX.Y/ + /latest/) and
the classic Helm repo (/charts/) are published by the Release workflow's
final jobs after every gate and artifact exists — a failed release
publishes nothing (#245). Push-to-main docs (/main/) are unaffected. Any
job failure auto-files an incident issue with the failed jobs and run link.
After the workflows finish:
- Paste the What's Changed section into the GitHub release body (above the generated boilerplate).
- Verify:
gh run list— Release, Pages — Docs, Pages — Helm Repo all green.gh release view v1.12.0— has…-complete.yamland…operator.yamlassets.helm repo update && helm search repo neo4j-operator/neo4j-operator --versions— new version listed.- Docs
/latest/and/v1.12/load and the version dropdown shows the release. cosign verify ghcr.io/priyolahiri/neo4j-kubernetes-operator:v1.12.0 …succeeds.- The release run's Install Confidence Gate job is green (it ran
automatically — no manual fresh-Kind
helm installneeded). - Comment on every issue the release fixes that was reported by someone else, naming the released version and asking the reporter to re-verify against it — externally-reported issues stay open until verified on a released version.
Retracting a release¶
release-retract.yml (manual dispatch, #246) safely unpublishes a dire
release. Doctrine: retract and supersede — never reuse a version (registry
layers, Helm caches, OLM catalogs and Rekor cache immutably). Always run
with dry-run: true first: the dry-run doubles as the grace-window probe,
printing either "window OPEN" (tag delete-and-re-cut still safe — nothing
published) or "window CLOSED at previous-good-tag, banners the
GitHub release as RETRACTED, removes the chart from both channels, republishes
docs from the previous good tag, and files a retraction-record issue with the
manual follow-ups (notably: a MERGED OperatorHub PR can only be superseded,
never yanked). Deleting the pinned image or the git tag are explicit opt-in
flags, default off.
Publishing to GitHub Pages¶
Both Pages workflows write to the gh-pages branch and share a gh-pages
concurrency group
so their pushes serialize instead of racing. Both authenticate with the
built-in GITHUB_TOKEN — no extra secrets.
Pages — Docs¶
pages-docs.yml publishes the MkDocs (Material) site as versioned docs
using mike. It first runs
mkdocs build --strict, so a broken internal link or a nav entry pointing at a
missing page fails the publish — treat it as a docs gate on main and tags.
What gets published where:
- Push to
main→ the/main/alias, a rolling preview of unreleased docs. Does not touchlatest. - Called from
release.yml(thepublish-docsjob, aftercreate-release; the workflow has no tag trigger of its own — publishing on tag push ran in parallel with the release gates and could advertise a version that never shipped, #245) → published as/vX.Y/(the patch is dropped, so a latervX.Y.Z+1overwrites the same/vX.Y/), and/latest/is moved to it.mike set-default latestalso points the site root at/latest/. - Manual dispatch → publish under an arbitrary
version-alias, optionally updatinglatest.
For readers: the site root redirects to /latest/ (newest release), and the
version dropdown at the top of every page (extra.version.provider: mike in
mkdocs.yml) switches between latest, each released /vX.Y/, and the /main/
preview. A page added on main appears under /main/ immediately and lands in
/vX.Y/ + /latest/ automatically on the next release tag — no manual step.
(The rolling-preview alias was historically called dev; it is now main.)
Pages — Helm Repo¶
pages-helm.yml packages the chart in charts/neo4j-operator and appends it
to the Helm repository index under /charts/ on gh-pages, so:
helm repo add neo4j-operator https://priyolahiri.github.io/neo4j-kubernetes-operator/charts
helm repo update
- Called from
release.yml(thepublish-helmjob, aftercreate-release; no tag trigger of its own) → packages and publishes that version. - Manual dispatch → package a specific existing tag.
OpenShift / OLM¶
OLM/OperatorHub is a supported manual install path but is not covered by CI — a smoke test needs an OpenShift cluster, which standard GitHub runners can't provide. Build and validate the bundle locally per OpenShift OLM.
Secrets & permissions¶
- CI / Extended Integration Tests: none beyond the default
GITHUB_TOKEN. - Release:
id-token: write(Cosign keyless OIDC) andpackages: write(push to GHCR). No stored signing keys. - Pages (Docs + Helm):
contents: writeto push togh-pages; the built-inGITHUB_TOKEN.