CI/CD Pipelines
A pipeline is the road every commit travels to production. This page explains the ideas first (integration, delivery, deployment, trunk-based development, build once and deploy many), then builds a pipeline for a Spring and Maven service, adds quality gates, scanning and safe secrets with OIDC, shows the same pipeline in Jenkins, GitLab CI and GitHub Actions, and finishes with environments, GitOps, rollbacks and supply-chain security.
Continuous Integration, Delivery and Deployment
Before pipelines, releasing meant a branch that lived for weeks, a merge day nobody enjoyed, a build made on somebody's laptop and a manual checklist. The cost of that process is not the hours it takes. It is that feedback arrives late: you learn that two changes do not work together long after the people who wrote them have forgotten the details.
CI/CD attacks that delay. The idea is to make the path from a commit to production automatic, repeatable and boring, so that releasing becomes a non-event and a failure is found minutes after it was introduced.
| Term | What it means | Human in the loop? | Typical trigger |
|---|---|---|---|
| Continuous Integration (CI) | Everyone merges to a shared branch often; each change is built and tested automatically | No | Every push / merge request |
| Continuous Delivery | Every change that passes CI is kept in a releasable state and can be deployed at any time | Yes, a manual approval for production | Green pipeline on the main branch |
| Continuous Deployment | Every change that passes all automated checks goes to production without anyone pressing a button | No | Green pipeline on the main branch |
- Trigger - what starts it: a push, a merge request, a tag, a schedule, a manual click, another pipeline.
- Stage - a group of jobs that run at the same point in the flow (build, test, package, deploy). Stages usually run in order.
- Job - one unit of work with a script, running on a runner / agent / executor (a VM or container).
- Artifact - a file a job produces and later jobs or humans consume (a JAR, a test report, a container image).
- Gate - a condition that can stop the flow: failing tests, a quality gate, a security scan, a manual approval.
- Environment - a place the artifact is deployed to (dev, staging, production), often with its own secrets and approvals.
Page 02 and page 03 cover what you deploy into (containers, Kubernetes). This page is the machinery that gets a commit there. Page 06 applies the same pipeline ideas to infrastructure code, and page 08 covers the rollout strategies a deploy job can use.
Trunk-Based Development and Other Strategies
The branching model decides how often code is integrated, so it decides how useful your CI is. A pipeline that runs on a branch that lives for three weeks does not give continuous integration. It gives continuous isolation.
| Strategy | How it works | Strengths | Costs |
|---|---|---|---|
| Trunk-based development | One main branch. Developers commit to it directly or through branches that live hours to a couple of days. Releases are cut from main (often by tag). | Small merges, fast feedback, no merge day, works naturally with CD | Needs good tests, feature flags for unfinished work, and discipline about keeping main green |
| GitHub flow / GitLab flow (simple) | Short feature branches, pull request or merge request, review, merge to main, deploy from main | Simple, review built in | Drifts into long branches if reviews are slow |
| Gitflow | Long-lived develop and main, plus feature/*, release/*, hotfix/* | Explicit release stabilisation, suits versioned products with several supported releases | Heavy ceremony, late integration, two sources of truth; poor fit for continuous deployment of a web service |
| Release branches from trunk | Trunk-based, plus a release/x.y branch cut for a version that must be patched later | Supports maintenance releases without Gitflow overhead | Fixes must be applied to trunk first, then cherry-picked |
- Fast, reliable CI. If the pipeline takes 40 minutes and fails randomly, nobody will merge small and often.
- Feature flags (also called toggles) so that incomplete features can be merged but stay dark. The flag is removed when the feature is finished; a flag that lives forever is a hidden branch.
- Branch by abstraction for large refactors: introduce an interface, switch callers one at a time, delete the old path.
- Pre-merge checks: the merge request pipeline runs build and tests, and the target branch refuses red merges.
- Small changes. Large changes are the real reason branches live long.
- If feature branches stay open for a sprint, integration still happens at the end, only now it is hidden behind a green pipeline on each branch. The signal that you are really doing CI: how old is the oldest open branch, and how long does a merge request wait for review?
For a typical Spring service deployed several times a week: short-lived branches, merge request with a mandatory pipeline and one review, merge to main, and the pipeline on main builds the artifact that goes to staging and production. Use a release branch only if you must patch old versions.
Pipeline Stages for a Java, Spring and Maven Service
Here is the shape of a pipeline for a typical Spring Boot service built with Maven. The commands are the same whichever CI tool runs them: the tool only decides where and when.
| Stage | Command (typical) | What it catches | Notes |
|---|---|---|---|
| Compile | mvn -B -ntp compile | Syntax and type errors | Cheapest feedback; fail here before anything expensive |
| Unit tests | mvn -B -ntp test | Logic errors in isolation | Run by the Surefire plugin; fast (no network, no containers) |
| Static analysis | Checkstyle, SpotBugs, SonarQube scanner, SAST | Style, bug patterns, vulnerable code patterns | Run in parallel with tests |
| Package | mvn -B -ntp package -DskipTests | Packaging problems (repackaged Spring Boot JAR) | Skip tests only because a previous stage already ran them |
| Integration tests | mvn -B -ntp verify | Wiring, SQL, messaging, HTTP contracts | Run by the Failsafe plugin in the verify phase; use Testcontainers or CI service containers |
| Image build | docker build, Buildpacks, or Jib | Container-level problems | Tag with an immutable identifier (chapter 04) |
| Image scan | Trivy, Grype or a registry scanner | Known CVEs in OS packages and libraries | Chapter 05 |
| Publish | Push the JAR and/or image to a repository | - | Only on the main branch |
| Deploy and verify | Deploy job, then smoke tests | Configuration and environment problems | Chapter 09 |
-B(batch mode) removes interactive prompts and colour.-ntp(--no-transfer-progress) removes the download progress noise that floods logs.- Use the Maven Wrapper (
./mvnw) so the pipeline and developers use the same Maven version, and pin the JDK in the image or the setup step, not "whatever the agent has". - Surefire runs unit tests during
test(default patterns include*Test,Test*,*Tests,*TestCase); Failsafe runs integration tests duringintegration-testand checks the result inverify(default patterns include*IT,IT*,*ITCase). Naming decides which plugin picks a test up. - Make the build reproducible: no
SNAPSHOTdependencies in release builds, and setproject.build.outputTimestampso the JAR does not change byte-for-byte on every build. - Produce test reports as XML (Surefire and Failsafe write JUnit-format files under
target/surefire-reportsandtarget/failsafe-reports) and have the CI tool publish them.
If a pipeline step is a command you cannot run on your laptop, debugging it means committing and waiting. Keep the logic in scripts and Maven goals, and keep the CI file as a thin list of "run this on that runner". This also makes moving between Jenkins, GitLab and GitHub Actions a small job instead of a rewrite.
Without a cache every job downloads the entire Maven dependency tree from the repository again. A cache stores ~/.m2/repository (or a project-local directory) between runs. The principle is the same in every tool:
- Key the cache on a hash of the files that define the dependencies (
pom.xml). When the POM changes the key changes and a fresh cache is built; otherwise it is reused. - Provide a fallback key (a prefix match) so that a changed POM still starts from the previous cache and only downloads what is new.
- Do not cache build output (
target/) as if it were a dependency. Pass build output between jobs as an artifact: a cache is a best-effort speed-up that may be missing, an artifact is a guaranteed hand-off. - Treat the cache as untrusted input for release builds: a poisoned or corrupted cache should not be able to change what you ship. Many teams build releases with a clean or read-only cache.
| Cache | Artifact | |
|---|---|---|
| Purpose | Speed: avoid re-downloading | Correctness: hand a result to a later job or a human |
| Guaranteed to exist? | No (miss, eviction, different runner) | Yes, until it expires |
| Typical content | Maven repository, npm cache | JAR, test report, coverage report, image digest file |
| Keyed by | Hash of pom.xml | Pipeline / job id |
- A cache that contains compiled classes can make a build pass with stale output ("works on the runner that had the cache"). Cache dependencies only; let the build produce its own output every time.
Versioning, Immutability and Build Once, Deploy Many
The most important rule in delivery pipelines is build once, deploy many: produce the artifact one time, store it, and promote exactly that artifact through every environment. If production runs a rebuild of the code that staging ran, then what you tested is not what you shipped, even if the source commit is identical. Different dependency resolution, a different base image or a different build date can change the result.
orders from commit a1b2c3d| Scheme | Example | Good for | Watch out for |
|---|---|---|---|
| Commit SHA | orders:a1b2c3d | Every build, perfect traceability to source | Not human-friendly; needs a map to release numbers |
| Semantic version | orders:1.4.2 | Libraries and anything customers or other teams consume | Someone must decide the number; automate it from tags or conventional commits |
| Version + SHA | orders:1.4.2-a1b2c3d | Readable and traceable | Longer tags |
| Build number | orders:1287 | Quick to implement | Resets or collides when you change CI system |
**latest** | orders:latest | Almost nothing in a pipeline | It changes under you. A deployment that says latest is not reproducible and cannot be rolled back by name |
- Immutable tags: configure the registry so a pushed tag cannot be overwritten (Amazon ECR calls this tag immutability; other registries have an equivalent). Without this, "the same tag" can silently mean different bytes.
- Deploy by digest (
image@sha256:...) when you need an absolute guarantee. A tag is a label that points to a digest; the digest identifies the content. - Snapshots stay out of production. A Maven
-SNAPSHOTversion is mutable by design. Release builds use fixed versions. - Record provenance: the commit SHA, pipeline URL and build time belong in the artifact's metadata (an image label,
META-INF/MANIFEST.MF, or Spring Boot'sbuild-info), so a running instance can answer "what exactly am I?".
- A pipeline with a "build-prod" job that compiles the code again after staging passed breaks the guarantee that production runs what was tested. Promote the artifact; vary only configuration.
- If the JAR or image contains staging URLs and credentials, you must rebuild to deploy elsewhere. Externalise configuration (environment variables, Spring profiles, config maps, a secrets manager) and keep the artifact environment-neutral.
Tests, Flaky Tests, Quality Gates and Scanning
A pipeline is only as trustworthy as its tests. The goal is fast, reliable feedback: the quickest tests first, the slowest later, and a failure that always means "something is really wrong".
| Layer | Spring/Java example | Speed | Run when |
|---|---|---|---|
| Unit | JUnit 5 + Mockito on a service class | Milliseconds | Every push |
| Slice / integration | @DataJpaTest, @WebMvcTest, Testcontainers PostgreSQL, @SpringBootTest | Seconds | Every merge request (or main) |
| Contract | Consumer-driven contracts between services (for example Spring Cloud Contract or Pact) | Seconds | Every merge request of provider and consumer |
| End-to-end | A browser or HTTP script against the deployed staging system | Minutes | After deploy to staging |
| Smoke | Three to ten calls proving the deployment is alive (health, one read, one write) | Seconds | After every deploy, including production |
A flaky test passes and fails on the same code. Each one teaches the team that red does not mean broken, and people start clicking "re-run" instead of reading failures. That destroys the value of the whole pipeline.
| Typical cause | What you see | Fix |
|---|---|---|
Sleeps and timing assumptions (Thread.sleep) | Fails only on a loaded runner | Wait for a condition (Awaitility) with a timeout, not a fixed delay |
| Shared mutable state between tests | Passes alone, fails in the suite, order-dependent | Isolate data; clean up; random order locally to expose it |
| Shared external service or database | Fails when two pipelines run at once | Per-test containers or schemas; do not share a test database |
| Dependence on the clock or time zone | Fails at midnight, month end, or on a different runner | Inject a Clock; fix the zone |
| Network calls to the internet | Intermittent DNS or rate-limit errors | Stub the call; use WireMock or a local container |
| Port collisions | "Address already in use" | Use random ports (server.port=0) and Testcontainers' mapped ports |
- Quarantine, do not ignore. Move a known-flaky test to a separate, non-blocking job with a ticket and a deadline, so it stops blocking everyone but is not forgotten.
- Retry is a smell, not a cure. A bounded retry on infrastructure steps (network downloads) is reasonable; retrying a failing assertion hides real race conditions.
A quality gate is an automated pass/fail decision based on measurements. If the gate fails, the pipeline stops and the change does not merge or ship. Typical conditions:
- Tests pass and the number of tests did not silently drop.
- Coverage on new code does not fall below an agreed level. Measure it with a coverage tool such as JaCoCo. Treat coverage as a smoke alarm, not a goal: a high number with weak assertions proves little.
- No new serious issues from static analysis (bugs, vulnerabilities, code smells) in the code that was just changed.
- No known critical vulnerabilities in dependencies or the container image (below).
- License checks if your organisation requires them.
SonarQube is a common tool for the analysis part. Its quality gate is a set of conditions evaluated on new code or on overall code during each analysis; the default built-in gate is called Sonar way, and you can define your own. The "new code" focus matters in older codebases: you do not have to fix ten years of history to stop things getting worse. The exact default thresholds change between versions, so read them in your own SonarQube instance instead of quoting a number.
Applying a gate to new code makes the rule enforceable on a legacy codebase and keeps pressure on the code being touched today. A gate on the whole codebase that starts out red gets switched off.
| Scan | Looks at | Examples of tools | When |
|---|---|---|---|
| SAST (static application security testing) | Your source code for insecure patterns | SonarQube, CodeQL, Semgrep | Every merge request, parallel to tests |
| SCA (software composition analysis) | Your dependency tree (pom.xml) against known-vulnerability databases | OWASP Dependency-Check, Dependabot/Renovate alerts, GitLab dependency scanning | Every merge request and on a schedule (new CVEs appear after you ship) |
| Container / image scan | OS packages and libraries inside the image | Trivy, Grype, registry scanners | After image build, before publish; again on a schedule |
| Secret scan | Committed passwords, tokens, keys | gitleaks, GitHub secret scanning, GitLab secret detection | Pre-commit and on every push |
| IaC scan | Terraform and Kubernetes manifests for misconfiguration | Checkov, tflint, Trivy config | Every merge request touching infra (page 06) |
The three flags used above exist in the Trivy CLI: --severity restricts what is reported, --ignore-unfixed hides vulnerabilities with no available fix, and --exit-code sets the process exit code when issues are found (so the job fails). Checked against trivy image --help, Trivy 0.76.1, 2026-10-31.
- A gate that fails on every CVE, including those with no fix and those in test-scope libraries, trains people to bypass it. Start by failing on fixable HIGH/CRITICAL findings in the runtime image, track the rest, and keep an explicit, time-limited suppression list with owners.
Secrets in Pipelines and OIDC Instead of Long-Lived Keys
A pipeline needs to do privileged things: push images, deploy, read a registry. That makes it one of the most valuable targets in your company. The rule is to give it as few secrets as possible, for as short a time as possible.
| Rule | Why | How |
|---|---|---|
| Never echo a secret | Logs are stored, shared and read by many people | Do not echo variables, do not run env or set -x in jobs that hold secrets. CI tools mask known secrets in logs, but masking is a safety net, and it cannot catch a transformed value (base64, split, URL-encoded) |
| Use the platform's secret store | Secrets in the repository are leaked forever through git history | Masked / protected variables (GitLab), credentials store (Jenkins), repository or environment secrets (GitHub) |
| Scope them | A feature-branch pipeline should not be able to deploy to production | Protected branches/environments, environment-level secrets, separate roles for plan and apply |
| Prefer short-lived credentials | A stolen key that expires in an hour is a small incident | OIDC federation (below), instead of an IAM user access key stored in CI |
| Do not give untrusted code your secrets | Pipelines from forks or untrusted merge requests can run attacker-written scripts | Do not expose secrets to fork pipelines; be careful with workflow triggers that run with secrets on external code |
| Rotate and audit | Credentials leak; you need to replace them quickly and know who used them | Automate rotation; use CloudTrail to see which role session did what |
The old way is to create an IAM user, generate an access key and store AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the CI system. Those keys do not expire, live in a third-party system, and must be rotated by hand. OpenID Connect (OIDC) federation removes the stored key altogether.
- The CI platform acts as an identity provider. For each job it can issue a signed JWT that states who the job is (repository, branch or environment, pipeline) and for whom it is meant (the audience).
- In AWS you register that platform as an IAM OIDC identity provider, and create an IAM role whose trust policy says: "principals authenticated by this provider, with this audience and this subject, may call
sts:AssumeRoleWithWebIdentity". - During the job, the token is exchanged with AWS STS for temporary credentials. Nothing long-lived is stored in the CI system. Only the role ARN, which is not a secret, is in the pipeline file.
The provider URL for GitHub is https://token.actions.githubusercontent.com, and for the official AWS action the audience is sts.amazonaws.com. The **sub** (subject) condition is the part that matters for security: without it, any repository on GitHub that obtains a token for that provider could try to assume the role. GitHub subjects look like repo:ORG/REPO:ref:refs/heads/main for a branch and repo:ORG/REPO:environment:NAME when the job uses a GitHub environment. For GitHub, the job must also have the permission id-token: write before it can request the token. Checked against docs.github.com (OIDC in AWS and OIDC reference), 2026-10-31.
On the GitLab side the id_tokens keyword creates a JWT in the variable you name, and aud sets its audience claim; GitLab's AWS guide uses aws sts assume-role-with-web-identity with that token and scopes the trust policy with claims such as sub (and, on GitLab.com, namespace_id and project_id). Checked against the GitLab CI/CD YAML reference and the "Configure OpenID Connect in AWS" page, 2026-10-31.
- If the trust policy only checks the audience, any project that can get a token from that provider may be able to assume the role. Always pin
sub(and prefer a branch, tag or environment form) and, on GitLab.com, the stableproject_id/namespace_id. Use a separate role for pull request pipelines (read-only, plan) and for the main branch (deploy).
- A quick
echo $DB_PASSWORDorenv | sortto debug a failing job writes the secret into logs that dozens of people can read and that are archived for months. Rotate any secret that appeared in a log, even if "masked".
A Declarative Jenkinsfile
Jenkins is the long-standing self-hosted automation server. A pipeline is defined in a **Jenkinsfile kept in the repository (pipeline as code). Jenkins supports two syntaxes. Declarative** (pipeline { ... }) has a fixed, validated structure and is the recommended starting point; scripted is plain Groovy and more flexible but easier to turn into spaghetti. A multibranch pipeline job scans the repository, creates a job per branch and pull request, and runs the Jenkinsfile found on each.
Everything above uses directives and steps from the Jenkins Pipeline syntax reference and the plugin step references (Credentials Binding, Pipeline: Basic Steps, JUnit, core archiveArtifacts). The file was read against the documentation; it was not executed on a Jenkins instance for this page, so run it through your controller's declarative linter before relying on it. The docker agent needs the Docker Pipeline plugin and Docker on the agent; the when { branch ... } condition works in multibranch pipelines.
| Piece | What it does | Notes |
|---|---|---|
agent none at top level | No executor is held for the whole run; each stage declares its own agent | Lets a stage such as the approval wait without occupying a build node |
agent { docker { image ... } } / agent { label ... } | Where the stage runs: a container, or any node with that label | Pin the image tag; use labels to target nodes that have the tools you need |
options { ... } | Pipeline-wide settings | timeout, disableConcurrentBuilds, buildDiscarder are documented options; a pipeline without a timeout can hang forever |
environment { ... } | Environment variables for the pipeline or a stage | A credentials(...) helper in this block binds a stored credential to a variable |
stages / stage / steps | The sequence of work | Steps are things like sh, junit, archiveArtifacts, stash |
parallel { ... } | Run nested stages at the same time | failFast true aborts the others when one fails |
when { ... } | Run a stage only if a condition holds | branch, changeRequest, expression, ...; beforeAgent true evaluates the condition before allocating an agent |
input | Pause for human approval | Wrap it in timeout so abandoned approvals do not block forever |
post { always / success / failure / ... } | Run after a stage or the whole pipeline depending on the result | Use always for publishing test results, failure for notifications, cleanup for tidy-up |
withCredentials([...]) | Expose credentials to a block of steps and mask them in the log | Provided by the Credentials Binding plugin: usernamePassword, string, file, ... |
stash / unstash | Pass files between stages that run on different agents | Good for a JAR; for large files use a repository, not stash |
- In
sh "docker login -p ${PASSWORD}"Groovy interpolates the secret before the shell sees it, and the value can leak into process listings and logs. Use single-quoted shell strings and let the shell read the environment variable (sh 'echo "$REG_PASS" | docker login --password-stdin ...'), exactly as the Jenkinsfile above does.
- Large Groovy blocks are hard to test and run only on Jenkins. Keep the Jenkinsfile as orchestration (which stage, which agent, which gate) and put build logic in Maven goals and shell scripts that a developer can run locally. If many repositories share the same pipeline, move it into a shared library.
Keep the controller light: do not build on it, run work on agents (preferably ephemeral containers or VMs), pin plugin versions, back up the configuration, and keep Jenkins and its plugins patched, because a plugin vulnerability on a server that holds deploy credentials is a direct route to production.
The Same Pipeline in Two More Dialects
GitLab CI and GitHub Actions are YAML-based and hosted with the code. The concepts match everything above; only the keywords differ. Both example files below were parsed with a YAML parser, and the GitLab keywords were checked against the GitLab CI/CD YAML reference. They were not run against a live GitLab or GitHub project.
| Keyword | What it does in the file above |
|---|---|
stages | Defines the order: build, test, package, deploy. Jobs in the same stage run in parallel |
default | Settings applied to every job: the Maven image, and interruptible: true so a newer pipeline on the same ref can cancel this one |
workflow:rules | Decides whether the pipeline is created at all: here only for merge requests and for the default branch, avoiding duplicate branch and merge request pipelines |
.maven-cache + extends | A hidden job (name starts with a dot) used as a template. cache:key:files derives the cache key from pom.xml; the pull policy downloads without uploading, and only compile uses pull-push |
needs | Turns the pipeline into a graph: unit-test starts as soon as compile finishes, without waiting for other jobs of the previous stage |
artifacts + expire_in | Passes the JAR to later jobs and deletes it after a day. artifacts:reports:junit lets GitLab show test results in the merge request |
rules with when: manual | The production deploy waits for a person to start it |
environment | Records deployments to named environments with a URL, giving a deployment history and rollback entry points in the UI |
resource_group | Ensures only one production deploy runs at a time across pipelines |
id_tokens | Requests a JWT with the given audience for OIDC to AWS (chapter 06) |
Two details are worth knowing. The cache is best-effort and keyed, while artifacts are guaranteed hand-offs (chapter 03). And rules replaced the older only/except, which should not be used in new pipelines.
- Trigger:
onlists events (pull_request,pushtomain). Jobs run on fresh runners;needsorders them. - **
permissions:** the file starts withcontents: readfor the whole workflow (least privilege) and the deploy job additionally requestsid-token: write, required for OIDC. - **
cache: maven** onactions/setup-javacaches the Maven repository, keyed on the POM files (documented in the setup-java README). No separate cache step is needed. - **
environment: staging** links the job to a GitHub environment, where you can configure required reviewers, wait timers and environment-scoped secrets. - **
concurrency** withcancel-in-progresscancels superseded runs on the same ref and saves runner minutes. - **
timeout-minutes** stops a stuck job from burning minutes.
The versions in this file (actions/checkout@v6, actions/setup-java@v5, actions/upload-artifact@v7, actions/download-artifact@v7, aws-actions/configure-aws-credentials@v6) are major-version tags that exist as of 2026-10-31, verified through the GitHub API. Newer majors may already exist, and the examples in GitHub's own docs use different ones. For real pipelines pin to a full commit SHA (chapter 10), and let Dependabot or Renovate propose updates.
| Concept | Jenkins | GitLab CI | GitHub Actions |
|---|---|---|---|
| Pipeline file | Jenkinsfile | .gitlab-ci.yml | .github/workflows/*.yml |
| Where code runs | Agents / nodes (labels) | Runners (tags) | Runners (runs-on) |
| Group of work | stage | stages + jobs | jobs |
| Run in parallel | parallel { } | Same stage, or needs | Jobs without needs |
| Conditions | when { } | rules: | if: expressions, on: filters |
| Pass files between jobs | stash/unstash, archiveArtifacts | artifacts | upload-artifact / download-artifact |
| Dependency cache | Plugin or persistent workspace | cache | actions/cache or setup-java cache |
| Manual approval | input | when: manual, protected environments | Environment required reviewers |
| Secrets | Credentials store, withCredentials | CI/CD variables (masked, protected) | Secrets (repo, org, environment) |
| OIDC token | Plugin-dependent | id_tokens | permissions: id-token: write |
| Reusable pieces | Shared libraries | include, extends, CI/CD components | Reusable workflows, composite actions |
The right tool is usually the one next to your code. Use GitLab CI when the code is in GitLab, GitHub Actions on GitHub. Jenkins earns its place when you need very custom or on-premises workflows, or you already operate it. What matters is that the pipeline logic lives in scripts, so a tool change is a translation of orchestration, not a rewrite.
Promotion, Approvals, Rollbacks and Pull-Based Deployment
An environment is a named target with its own configuration, secrets, access rules and (often) its own cluster or account. Promotion means moving the same artifact from one environment to the next after checks pass.
| Environment | Purpose | Deploy trigger | Checks before the next step |
|---|---|---|---|
| dev / preview | Fast feedback on every merge or merge request; may be ephemeral | Automatic | Smoke tests |
| staging | Production-like system to verify the exact artifact | Automatic after main is green | Smoke + end-to-end, performance checks as needed |
| production | Real users | Automatic (deployment) or after approval (delivery) | Post-deploy smoke tests, health and error-rate monitoring (page 07) |
- Approvals are gates for people: a required reviewer on a GitHub environment, a
when: manualjob or protected environment in GitLab, aninputstep in Jenkins. Use them where the business or regulation requires a human decision, not as a substitute for tests. - Separation of duties: the person who wrote the change should not be the only approver of its production deploy, if your compliance rules say so. Most CI systems can enforce who may approve or deploy.
- Deployment windows and freezes can be enforced in the pipeline (a schedule or a manual job) as well as by policy.
- Serialise deployments to one environment (a GitLab
resource_group, a GitHubconcurrencygroup, JenkinsdisableConcurrentBuilds) so that two pipelines cannot deploy to production at once.
A deployment is only safe if you can undo it quickly. The common approaches are:
| Approach | How | Caveat |
|---|---|---|
| Redeploy the previous artifact | Run the deploy job again with the previous image tag. Works because artifacts are immutable and kept | Needs the old tag to still exist; retention rules must not delete it |
| Revert in Git (GitOps) | git revert the change in the config repo; the controller applies the old state | Slower than a button, but fully audited and consistent with Git |
| Platform rollback | kubectl rollout undo, Argo CD rollback to a previous synced revision | Fast, but Git and cluster now disagree until you reconcile |
| Roll forward | Ship a fix quickly | Right when the bug is small and the pipeline is fast; wrong when data was corrupted |
| Feature flag off | Disable the feature without redeploying | Needs the flag to exist and to default safe |
- Rolling the application back does not roll the schema back. Make migrations backward compatible (expand, migrate, contract): add the new column first, deploy code that works with both, and remove the old column in a later release. Otherwise the "previous version" you roll back to cannot run against the new schema.
GitOps applies the pipeline idea to deployment: the desired state of the environment (Kubernetes manifests, Helm values, Kustomize overlays) lives in a Git repository, and an agent running inside the cluster continuously compares it with the live state and makes them match. The diagram at the top of this chapter shows the difference from a classic push deploy.
| Push (CI deploys) | Pull (GitOps controller) | |
|---|---|---|
| Who has cluster credentials | The CI system | Only the controller inside the cluster |
| Network direction | CI must reach the cluster API | The cluster reaches out to Git (and a registry) |
| Source of truth | The last pipeline run | The Git repository |
| Drift (someone changes the cluster by hand) | Invisible until the next deploy overwrites it | Detected continuously; reported, or reverted if self-healing is on |
| Audit trail | Pipeline logs | Git history: who changed what, when, and why (a pull request) |
| Rollback | Re-run a job | git revert and let the controller apply it |
sourceis the Git repository, revision and path that hold the manifests;destinationis the cluster and namespace. TheapiVersion: argoproj.io/v1alpha1/kind: Applicationform is the one in the Argo CD documentation.syncPolicy.automatedmakes Argo CD apply changes by itself. By default automated sync does not delete resources removed from Git;prune: trueenables deleting them, andselfHeal: truemakes it revert manual changes to the cluster (documented in the Argo CD automated sync page).- Without
automated, Argo CD shows the application as OutOfSync and waits for someone to press Sync. That is the GitOps version of a manual approval. - Flux takes the same approach with separate controllers: a source controller fetches Git or OCI sources, and Kustomize and Helm controllers apply them (a notification controller sends events). Pick one and learn its model; the concepts transfer.
A typical split is two repositories: the application repo (code, tests, Dockerfile) and a config repo (manifests or Helm values per environment). The application pipeline builds the image, pushes it, and commits the new image tag to the config repo (or opens a pull request there). The GitOps controller does the actual deployment. This also gives promotion a natural form: a promotion from staging to production is a pull request that changes the tag in overlays/prod.
- Git is forever and widely readable. Keep secrets out of manifests: use a secrets manager with an in-cluster operator (for example External Secrets) or encrypt them before committing (for example Sealed Secrets or SOPS), and only commit the reference or the ciphertext.
Pipeline Speed, Cost, Supply-Chain Security and Common Mistakes
A slow pipeline is a tax on every developer and a reason to batch changes, which makes failures harder to diagnose. A pipeline that uses too many minutes is a bill. The levers are similar in every tool:
| Lever | What to do | Trade-off |
|---|---|---|
| Run less | Path filters (skip the Java pipeline when only docs changed), skip stages with nothing to do | Filters must be right or you skip needed checks |
| Run in parallel | Split independent work (unit tests, static analysis, scans); shard slow test suites | More runners at once; parallel jobs need isolated resources |
| Fail fast | Cheap checks first (format, compile, unit); stop on the first failure | You may fix one error at a time instead of seeing all |
| Cache | Dependencies keyed on pom.xml; Docker layer cache; build cache | Stale or poisoned cache risk (chapter 03) |
| Cancel redundant runs | GitLab interruptible, GitHub concurrency with cancel-in-progress | Never cancel a deploy or release run in progress |
| Right-size runners | Measure before buying bigger machines; builds are often I/O or network bound | Cost per minute vs minutes saved |
| Optimise the Dockerfile | Copy dependency files first so layers are reused; multi-stage builds; small base image | More Dockerfile complexity |
| Set timeouts and retention | Job timeouts, artifact expiry, log and cache retention | Do not expire artifacts you still need for rollback |
Look at the pipeline duration split by stage over a week. The slowest stage and the most frequently run pipeline are where optimisation pays. A common target: the merge request pipeline gives a result in the time it takes to get a coffee, not to eat lunch.
Your pipeline compiles other people's code (dependencies), runs scripts and holds deploy credentials. An attacker who compromises any of these ends up in production. Defences are mostly about knowing and pinning what you run:
- Pin versions. Do not use floating references such as
latestfor base images, build tools or CI actions. Pin images to a tag or digest, and pin third-party actions to a full-length commit SHA, which GitHub's security guidance recommends because a tag can be moved to a different commit. - Automate updates with Dependabot or Renovate so pinned versions do not rot, and review the pull requests.
- Lock dependencies. Use a lockfile or a controlled Maven repository manager (Nexus, Artifactory) as a proxy so builds do not pull directly from arbitrary sources.
- Least privilege. Default workflow permissions to read-only; give each job the minimum; separate roles for plan and apply, for build and deploy.
- Isolate runners. Prefer ephemeral runners (fresh VM or container per job). A long-lived shared runner can leak files and credentials between jobs.
- Do not run untrusted code with secrets (forks, untrusted merge requests), and avoid interpolating untrusted input (branch names, PR titles) into shell commands in workflow files.
- Produce an SBOM and sign artifacts where your organisation requires it. The SLSA framework (Supply-chain Levels for Software Artifacts, slsa.dev) is a specification that defines progressively stronger levels of build integrity and provenance; use it as a vocabulary for how far along you are.
- Rebuilding for production instead of promoting the tested artifact.
- Long-lived cloud keys stored in CI when OIDC is available.
- **
latesttags** and mutable artifacts: no reproducibility, no rollback. - Ignoring flaky tests until nobody trusts red builds.
- A pipeline only the CI tool can run: logic hidden in UI configuration or giant YAML, impossible to reproduce locally.
- No timeouts on jobs and approvals, so stuck runs block the queue.
- Shared mutable test environments that make results depend on who else is running.
- Skipping the gate "just this once" until the gate means nothing.
- Treating the pipeline as a snowflake: no review, no versioning of the pipeline definition. It is production code.
Questions You Should Be Able to Answer
Crisp answers for the questions that come up in interviews on this topic.
- Q: What is the difference between continuous integration, continuous delivery and continuous deployment?
CI: developers merge often and every change is built and tested automatically. Continuous delivery: every change that passes the pipeline is releasable, and production deploys need a manual decision. Continuous deployment: no manual step, a green pipeline goes to production by itself. - Q: What does "build once, deploy many" mean and why does it matter?
The artifact is built a single time, stored under an immutable identifier, and the same bytes are promoted through every environment with only configuration changing. It guarantees that production runs what was tested, and it makes rollbacks a redeploy of an earlier artifact. - Q: Why is trunk-based development a good match for CI/CD?
Short-lived branches mean small merges, early integration and fast feedback. Unfinished work is hidden behind feature flags, so main stays releasable. Long-lived branches delay integration, which is the problem CI is meant to solve. - Q: How do you speed up a slow Maven pipeline?
Cache~/.m2keyed onpom.xml, run independent stages in parallel, fail fast with cheap checks first, avoid rebuilding, split slow test suites, skip pipelines when only docs changed, cancel superseded runs, and measure per-stage time before optimising. - Q: What is the difference between a cache and an artifact?
A cache is a best-effort speed-up (dependencies) that may be missing; an artifact is a guaranteed output handed to later jobs or users (a JAR, a report). Do not rely on a cache for correctness and do not cache build output. - Q: How should secrets be handled in a pipeline?
Store them in the platform's secret store, scope them to protected branches and environments, never print them, do not expose them to untrusted code, rotate them, and prefer short-lived credentials via OIDC instead of long-lived keys. - Q: How does OIDC federation from GitHub Actions or GitLab to AWS work?
The CI platform issues a signed JWT for each job. AWS is configured with the platform as an IAM OIDC identity provider and a role whose trust policy allowssts:AssumeRoleWithWebIdentityfor a specific audience and subject. The job exchanges the token for temporary credentials. No long-lived key is stored, and the subject condition limits which repository, branch or environment can assume the role. - Q: What is a flaky test and what do you do about it?
A test that sometimes fails on unchanged code, usually through timing, shared state, external dependencies or clocks. Fix the cause, quarantine it into a non-blocking job with an owner and deadline meanwhile, and avoid masking it with blanket retries. - Q: What is a quality gate?
An automated pass/fail rule based on measurements, such as tests passing, coverage and no new serious issues on new code, and no critical vulnerabilities. A failing gate stops the merge or the release. Applying it to new code makes it workable on legacy codebases. - Q: Declarative vs scripted Jenkins pipelines?
Declarative has a structured, validatedpipeline { }syntax with stages, agents,postandwhen, and is the recommended default. Scripted is free-form Groovy: more flexible, easier to make unmaintainable. Complex logic should live in scripts or a shared library either way. - Q: How do
rules,needsandworkflow:rulesdiffer in GitLab CI?rulesdecides whether a job is added to a pipeline,needslets a job start as soon as the jobs it depends on finish regardless of stage, andworkflow:rulesdecides whether the whole pipeline is created. - Q: Push-based deployment vs GitOps?
In push, CI holds cluster credentials and applies changes. In GitOps an in-cluster controller (Argo CD, Flux) pulls the desired state from Git and reconciles. GitOps removes cluster credentials from CI, gives a Git audit trail, and detects drift. - Q: How do you roll back a bad release?
Redeploy the previous immutable artifact, or revert the change in the config repo for GitOps, or turn off a feature flag. Make database migrations backward compatible so the old code can still run. Decide beforehand when to roll forward instead. - Q: How would you secure a CI pipeline against supply-chain attacks?
Pin base images and actions (commit SHA), use lockfiles and a proxy repository, scan dependencies and images, give jobs least privilege, use ephemeral isolated runners, do not run untrusted code with secrets, sign and record provenance of artifacts. - Q: Why not tag images
latest?
It is mutable, so a deployment cannot be reproduced or rolled back by name, and different nodes may run different content under the same tag. Use immutable tags (commit SHA or version) or digests.
Cheat Sheet and Recap
| Topic | Remember | Tool / keyword |
|---|---|---|
| CI vs CD vs CD | Integrate often; always releasable; no human gate | Delivery needs approval, deployment does not |
| Branching | Short-lived branches into main, flags for unfinished work | Trunk-based development |
| Java build | Unit tests in test, integration tests in verify | Surefire, Failsafe, mvn -B -ntp verify |
| Cache | Dependencies only, keyed on pom.xml | GitLab cache:key:files, setup-java cache: maven |
| Artifact | Build once, immutable tag or digest, promote | Never latest; same image in every environment |
| Gates | Tests, new-code coverage, static analysis, scans | SonarQube quality gate, Trivy --exit-code |
| Secrets | Masked, scoped, never printed; OIDC over keys | GitHub id-token: write, GitLab id_tokens |
| Jenkins | Declarative, agents per stage, post, withCredentials | pipeline { agent stages post } |
| GitLab | Stages, needs graph, rules, environment | .gitlab-ci.yml |
| GitHub Actions | Least-privilege permissions, pin actions by SHA | .github/workflows/*.yml |
| Approvals | Humans for decisions, tests for correctness | input, when: manual, environment reviewers |
| GitOps | Git is the desired state; controller pulls and reconciles | Argo CD syncPolicy.automated, Flux |
| Rollback | Redeploy previous artifact or revert in Git | Backward-compatible migrations |
| Supply chain | Pin, lock, scan, least privilege, ephemeral runners | Dependabot/Renovate, SLSA vocabulary |
- A pipeline makes the path from commit to production automatic, repeatable and boring, so that failures are found in minutes.
- Integrate often. Without short-lived branches, CI is only a build server.
- Build once, deploy many. Immutable artifact, environment-specific configuration injected at deploy time.
- Fast checks first, expensive ones later, gates that always mean something. Treat flaky tests as pipeline bugs.
- No long-lived cloud keys in CI. Use OIDC with a tight
subcondition, and keep secrets out of logs and out of untrusted code. - The tool is a dialect. Jenkins, GitLab CI and GitHub Actions share the same concepts. Keep logic in scripts and Maven goals.
- GitOps pulls instead of pushes, which removes cluster credentials from CI and makes drift visible.
- Pin and least-privilege everything the pipeline touches. The pipeline is production code and a prime target.
Checked on 2026-10-31: Jenkins directives and steps against jenkins.io (Pipeline syntax and plugin step references); GitLab keywords (rules, needs, cache:key:files, cache:policy, artifacts:expire_in, environment, resource_group, interruptible, id_tokens) against the GitLab CI/CD YAML reference and the GitLab AWS OIDC page; GitHub OIDC requirements (id-token: write, provider URL, aud and sub conditions) against docs.github.com; action major versions against the GitHub API; Argo CD Application and automated sync (prune, selfHeal) against the Argo CD docs; Trivy flags against trivy image --help (0.76.1); SonarQube quality gate concepts against the SonarQube docs. The YAML and JSON files were parsed; the Jenkinsfile, GitLab and GitHub files were not executed on real servers. Tool versions move quickly: re-check keywords against your version.
- 06 Infrastructure as Code - run the same pipeline discipline (plan on pull request, apply on merge, OIDC) for infrastructure.
- 08 Deployment Strategies - rolling, blue-green and canary releases that a deploy job can use.
- 03 Kubernetes Core - what GitOps controllers apply.
- DevOps Map - the whole section.