CI/CD Pipelines
The CI/CD Boundary
:::note CI’s responsibility ends here
CI’s job ends at “signed image pushed + GitOps PR opened.” Deployment is ArgoCD’s pull-based job. GitHub Actions never holds a kubeconfig, never runs kubectl, and never runs helm upgrade against a live cluster. If a CI step touches the cluster directly, it is a security violation and an architectural regression.
:::
This separation means:
- The blast radius of a compromised CI token is bounded to the registry and the GitOps repository.
- Cluster state is always reconcilable from Git without involving CI.
- Deployment history is the GitOps commit log, not CI run logs.
Pipelines Overview
Three pipeline triggers cover the full delivery lifecycle:
flowchart TD
subgraph PR ["PR Pipeline (on pull_request)"]
pr_lint["Lint + unit tests\n(go test, eslint, buf lint)"]
pr_build["Multi-arch build\n(no push — cache only)"]
pr_scan["Vulnerability scan gate\n(trivy — no push on fail)"]
pr_helm["Helm lint + template\ndry-run"]
pr_preview["Ephemeral preview env\n(ArgoCD ApplicationSet\nPR generator)"]
pr_lint --> pr_build --> pr_scan
pr_lint --> pr_helm
pr_build --> pr_preview
end
subgraph MAIN ["Merge-to-main Pipeline (on push to main)"]
m_build["Multi-arch build\n(linux/amd64 + linux/arm64)"]
m_sbom["SBOM generation\n(syft → SPDX + CycloneDX)"]
m_scan["Vuln scan gate\n(trivy — blocks on CRITICAL/HIGH)"]
m_sign["cosign sign (keyless)\n+ SLSA provenance\n(slsa-github-generator)"]
m_push["Push by digest\nto OCI registry"]
m_gitops["Open GitOps PR\n(bump image.digest in envs/dev/)"]
m_build --> m_sbom --> m_scan --> m_sign --> m_push --> m_gitops
end
subgraph RELEASE ["Release Pipeline (on push tag v*)"]
r_changelog["Generate changelog\n+ GitHub Release"]
r_staging["Open GitOps PR\n(bump digest in envs/staging/)"]
r_helm_pub["Publish Helm chart\nto chart registry"]
r_compose["Publish Docker Compose\nbundles"]
r_changelog --> r_staging
r_changelog --> r_helm_pub
r_changelog --> r_compose
end
PR pipeline validates correctness and security without producing a deployable artifact. The ephemeral preview environment uses the build cache image (unattested) for review-only purposes.
Merge-to-main pipeline produces the authoritative signed, attested image. The digest from this pipeline is what flows through staging to production.
Release pipeline promotes a specific digest to the staging environment and publishes distribution artifacts (Helm chart, Compose bundles, GitHub Release).
Supply-Chain Security
Every image that leaves CI carries a verifiable provenance chain:
flowchart LR
build["Built OCI image\n(multi-arch manifest)"]
sbom["SBOM\n(syft)\nSPDX + CycloneDX\nattached as OCI artifact"]
vuln["Vuln scan\n(trivy)\nblocks on unfixed\nCRITICAL/HIGH"]
sign["cosign keyless sign\nGitHub OIDC identity\n→ Fulcio short-lived cert\n→ signature in Rekor"]
slsa["SLSA provenance\n(slsa-github-generator L2)\nbuild inputs + workflow\n+ runner attested"]
registry["OCI Registry\n(image + SBOM +\nsignature + provenance)"]
admission["Cluster Admission\n(cosign policy controller\nor Kyverno)\nverifies before scheduling"]
build --> sbom --> vuln --> sign --> slsa --> registry --> admission
:::tip Supply-chain: No long-lived signing keys
cosign keyless signing uses the GitHub Actions OIDC token to authenticate to Fulcio, which issues a short-lived certificate for the signing operation. The certificate and signature are recorded in the Rekor transparency log. There are no long-lived signing keys to rotate, leak, or revoke. Verification only requires the Rekor log and the Fulcio root — no CI secret needed.
:::
Admission verification ensures that only images with a valid cosign signature (from the expected GitHub Actions workflow identity) and a valid SLSA attestation can be scheduled in production namespaces. An image built outside the official pipeline — even one pushed to the correct registry — is rejected at admission.
Cloud Auth: OIDC
GitHub Actions uses OpenID Connect (OIDC) to assume short-lived cloud IAM roles. No long-lived cloud credentials exist in GitHub Secrets.
| Operation | Cloud Identity | Scope |
|---|---|---|
| Push image to OCI registry | OIDC → registry write role | Scoped to bitvault/* repositories |
| OpenTofu plan/apply | OIDC → Terraform IAM role | Scoped to the target environment’s cloud project |
| Read/write secrets in Vault | OIDC → Vault JWT auth role | Scoped to the pipeline’s Vault policy |
| cosign signing (Fulcio) | OIDC token direct to Fulcio | Scoped to the GitHub Actions workflow identity |
The OIDC claim includes the repository, workflow, ref, and environment, allowing cloud IAM policies to restrict access to specific branches or environments (e.g., only the release workflow on refs/tags/v* may write to the prod registry namespace).
Monorepo CI Ergonomics
The monorepo structure requires discipline to avoid rebuilding everything on every change:
| Strategy | Implementation |
|---|---|
| Path filters | Each service’s workflow triggers only when its source paths change (paths: filter in GitHub Actions). A change to apps/web/ does not trigger cmd/bitvaultd/ build. |
| Reusable workflows | Common steps (build, scan, sign, publish) are factored into reusable workflow files (deploy/.github/workflows/reusable-*.yaml) called via workflow_call. |
| Matrix builds | Multi-arch builds run as a matrix (strategy.matrix.platform: [linux/amd64, linux/arm64]) to parallelize across runners. |
| BuildKit layer cache | Registry-backed cache (type=registry,ref=…/cache) warms BuildKit across runs. Layer cache hits are confirmed in build output. |
| Pinned action SHAs | Every uses: reference in workflow files pins to a full commit SHA, not a mutable tag. Dependabot keeps these current. |
| Secret scanning | gitleaks runs as a pre-commit hook and a CI check. Push rejected on detected secrets. |
| Branch protection | main requires: status checks pass (lint, test, scan), PR review, no force-push, no direct push. Release tags require signed commits. |
The effect: a change to internal/storage/ triggers only the bitvaultd and bitvault-worker builds (which depend on it), not bitvault-web or bitvault-cli. Total CI wall-clock time for a typical service-level change is under 10 minutes.