Skip to content

CI/CD Design

What this page covers

How this repository keeps required status checks fast without ever leaving one stuck pending — via step-level gating driven by a centralized change-detection classifier.

Workflows

WorkflowTriggerPurpose
ci.yamlpush to main, every PRRequired: changes, lint, test, build, docs, chart
e2e.yamlpush to main, every PRKWOK-based Karpenter e2e (single e2e job)
release.yamlpush of a v* tagMulti-arch image + Helm chart OCI + attestation + GitHub Release
pages.yamlpush to main (docs paths), manualVitePress site → GitHub Pages

The pending trap

Why this matters

A required check with no conclusion is not "green" — it is stuck pending. A PR can never merge while any required check sits in that state.

Branch protection on main names exact required status checks. If a workflow or job is skipped outright (via paths-ignore or job-level if:), GitHub never reports a conclusion → stuck pending.

Solution: always run the job, skip only its expensive steps when nothing they care about changed.

  • Every step that matters carries a per-step if:
  • The job always reaches a real conclusion (success) in seconds when inputs are untouched
  • Full work runs only when relevant files change

This is why the repository does not use paths-ignore or job-level if:.

ci.yaml: the changes job

How it works

  1. A dedicated changes job computes gating flags (always runs, no if:)
  2. lint, test, build, docs, chart each declare needs: changes and read its outputs
  3. Classification logic lives in one place (DRY)

The classifier is .github/scripts/detect-ci-changes.sh:

  • A small, pure shell script (no git, no GitHub Actions context)
  • Reads newline-separated changed paths on stdin
  • Prints five booleans

Input sources

ContextInput
Pull requestgit diff --name-only "$BASE_SHA" HEAD
Push to mainAll flags true (no base to diff; always run everything)

Self-test

.github/scripts/detect-ci-changes.test.sh unit-tests the classifier against a table of sample path sets. It runs on every CI invocation — gating logic cannot silently rot.

The same always-running job executes .github/scripts/check-release-version-sync.sh. A PR cannot leave the chart, README EN/JA, agent/contributor status, or runbook EN/JA on different current release versions.

Path → flag → job

Path patternFlagGated jobs/steps
*.go, go.mod, go.sum, api/, config/, .golangci.ymlgolint, test, build
charts/**chartchart
Dockerfile, .dockerignoredockerbuild
docs/**, README*.md, package files, simulator Go sourcesdocsdocs
Makefile, aqua.yaml, aqua-policy.yaml, aqua/**, .github/workflows/ci.yaml, .github/scripts/**infralint, test, build, chart

Resulting step gates

JobRuns real steps when
lintgo || infra
testgo || infra
buildgo || docker || infra
docsdocs
chartchart || infra
  • infra is deliberately broad: CI workflow, shared Makefile, or aqua toolchain pins can affect all four non-doc jobs, so it fans out to them rather than guessing. The docs flag separately follows docs-site and simulator inputs, including the shared build-toolchain files (go.mod, go.sum, Makefile, aqua.yaml) that produce the wasm module — so those set docs in addition to go or infra.

e2e.yaml: single-job in-step detection

A different shape from ci.yaml on purpose:

  • One job (e2e) = one required check
  • Change detection runs as the job's first step (not a separate upstream job)
  • No DRY benefit from a separate changes job when there's only one consumer

Detection scope

Inspects the diff for e2e-relevant paths:

  • internal/, cmd/, charts/, test/e2e/
  • go.mod, go.sum, Makefile, Dockerfile, aqua.yaml
  • .github/workflows/e2e.yaml
  • Excludes pure Markdown changes

Behavior

ContextResult
PR touching none of those pathse2e: success in seconds
PR touching relevant pathsFull ~45-minute suite
Push to mainAlways full suite

release.yaml: tag-driven OCI publish

Triggers only on v* tags — not part of branch protection, so no pending-check exposure.

Four sequential jobs

JobPurpose
guardFail if tag, chart, or current-release markers disagree
imageMulti-arch build + push + SBOM + SLSA provenance + attest + cosign
chartPackage Helm chart → OCI push + attest + cosign
releaseCreate GitHub Release with chart + SBOM attached

Image job details

Before publishing, guard runs both version checks:

  • check-chart-version.sh requires tag == chart version == appVersion
  • check-release-version-sync.sh requires matching current-release markers in README EN/JA, AGENTS.md, CONTRIBUTING.md, and runbook EN/JA

The same sync check already runs on every PR, so a release-preparation omission is normally caught before merge and is checked again before publication.

  • Architectures: linux/amd64, linux/arm64
  • Registry: ghcr.io/akashisn/node-rotation-controller
  • Tags latest unless hyphenated pre-release (e.g. v0.4.0-rc.1)
  • In-registry SBOM + SLSA provenance from build
  • Attestation on the pushed index digest + keyless cosign signature
  • Emits SPDX SBOM for the Release

Chart job details

  • Packages Helm chart → oci://ghcr.io/akashisn/charts
  • Attests and keyless-signs the pushed manifest digest

Release job details

  • Downloads packaged chart + SPDX SBOM
  • Creates GitHub Release with both attached
  • Marks as pre-release for hyphenated tags

Permissions

Scoped per job (not workflow-level):

JobPermissions
image, chartid-token, attestations, packages: write
releasecontents: write

Attestation runs for pre-release tags too. See SECURITY.md for consumer verification.

pages.yaml: docs deploy

Builds this VitePress site with npm run docs:build and deploys to GitHub Pages.

Triggers

  • Pushes to main touching: docs/**, README.md, README.ja.md, package.json, package-lock.json, or the workflow file
  • workflow_dispatch for manual runs

Not a required check

This workflow publishes docs — it does not gate merges. No change-detection gating needed.