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:

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.