# GCP DevOps & CI/CD Platform — Part 3: Continuous Integration with Cloud Build

> **Series:** GCP DevOps & CI/CD Platform (3 of 6) — aligned to Professional Cloud DevOps Engineer (PCDE) exam sections 1-2
> **Part 1:** `01-designing-a-devops-organization.md` — Designing a DevOps Organization
> **Part 2:** `02-infrastructure-as-code-and-gitops.md` — Infrastructure as Code & GitOps
> **Part 3:** This file — Continuous Integration with Cloud Build
> **Part 4:** `04-continuous-delivery-with-cloud-deploy.md` — Continuous Delivery with Cloud Deploy
> **Part 5:** `05-managing-environments-and-secrets.md` — Managing Environments, Configuration & Secrets
> **Part 6:** `06-securing-the-deployment-pipeline.md` — Securing the Deployment Pipeline & Dev Environments
> **Questions:** `questions.md`

## Table of Contents

1. [What Cloud Build Actually Does](#what-cloud-build-actually-does)
2. [Anatomy of a Build: Steps, Substitutions, and the Shared Workspace](#anatomy-of-a-build-steps-substitutions-and-the-shared-workspace)
3. [Triggers: What Starts a Build](#triggers-what-starts-a-build)
4. [Artifact Registry: Where the Build's Output Lives](#artifact-registry-where-the-builds-output-lives)
5. [Private Pools: Building Inside Your Own VPC](#private-pools-building-inside-your-own-vpc)
6. [Designing a Pipeline: What CI Actually Needs to Verify](#designing-a-pipeline-what-ci-actually-needs-to-verify)
7. [Monorepos: Path-Filtered Triggers Instead of One Trigger Per Service](#monorepos-path-filtered-triggers-instead-of-one-trigger-per-service)
8. [Third-Party Tooling: Cloud Build Is Not GCP-Exclusive](#third-party-tooling-cloud-build-is-not-gcp-exclusive)
9. [A Full Worked Pipeline: Meridian's `shipment-api` Build](#a-full-worked-pipeline-meridians-shipment-api-build)
10. [Common Mistakes and Interview Traps](#common-mistakes-and-interview-traps)
11. [Worked Practice Problems](#worked-practice-problems)
12. [Summary and What's Next](#summary-and-whats-next)

## What Cloud Build Actually Does

**Cloud Build takes a source change and turns it into a tested, versioned, deployable artifact — nothing more, nothing less.** It does not deploy anything itself; that's Cloud Deploy's job, covered in Part 4. Cloud Build's entire job is the "CI" half of "CI/CD": compile, test, scan, and package, ending with an artifact sitting in Artifact Registry, ready for something else to promote it.

Every Cloud Build run executes as a sequence of steps, each one a container image running a command against a shared workspace — the same filesystem checked out from source, visible to every step in order. This container-per-step model is what makes a build config portable and predictable: a step that runs `go build` is just "run this container image with this command," not a magic built-in Cloud Build feature, which means a team can swap in any container image (a custom linter, a proprietary scanner) as easily as reaching for one of Google's own prebuilt builder images.

```mermaid
flowchart LR
    Source["Source push<br/>(GitHub/GitLab/CSR)"] --> Trigger["Trigger fires"]
    Trigger --> Step1["Step 1: Install deps<br/>(container)"]
    Step1 --> Step2["Step 2: Run tests<br/>(container)"]
    Step2 --> Step3["Step 3: Build image<br/>(container)"]
    Step3 --> Step4["Step 4: Scan image<br/>(container)"]
    Step4 --> AR["Push to<br/>Artifact Registry"]

    classDef trigger fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    classDef step fill:#f0e9fb,stroke:#6d43c0,color:#10161c
    classDef output fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    class Source,Trigger trigger
    class Step1,Step2,Step3,Step4 step
    class AR output
```

**What to notice**: every step shares the same workspace, so `Step 3`'s built binary is exactly the file `Step 2`'s test run just verified — there's no separate copy or handoff step, which is what makes "what was tested is what ships" (Part 1's Artifact Registry principle) an actual guarantee rather than an assumption.

## Anatomy of a Build: Steps, Substitutions, and the Shared Workspace

A `cloudbuild.yaml` file is the build's own declaration — checked into the source repository itself, so the build definition is versioned right alongside the code it builds.

```yaml
# cloudbuild.yaml for Meridian's shipment-api — real steps, real
# substitutions, nothing abbreviated for the tutorial
steps:
  - id: 'run-unit-tests'
    name: 'golang:1.24'
    entrypoint: 'go'
    args: ['test', './...']

  - id: 'build-image'
    name: 'gcr.io/cloud-builders/docker'
    args:
      - 'build'
      - '-t'
      - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/meridian-images/shipment-api:${SHORT_SHA}'
      - '.'

  - id: 'push-image'
    name: 'gcr.io/cloud-builders/docker'
    args:
      - 'push'
      - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/meridian-images/shipment-api:${SHORT_SHA}'

substitutions:
  _REGION: 'us-central1'

images:
  - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/meridian-images/shipment-api:${SHORT_SHA}'

options:
  logging: CLOUD_LOGGING_ONLY
```

**Two kinds of substitution variables show up here, and knowing which is which matters**: `${PROJECT_ID}` and `${SHORT_SHA}` are Cloud Build's own *built-in* substitutions — Cloud Build fills these in automatically from the triggering event, with no declaration needed. `${_REGION}` is a *user-defined* substitution — the leading underscore is mandatory, it's how Cloud Build tells the two kinds apart, and it's declared explicitly in the `substitutions:` block (or overridden per-trigger without touching the YAML file at all).

| Substitution | Kind | Resolves to |
|---|---|---|
| `$PROJECT_ID` | Built-in | The project running the build |
| `$SHORT_SHA` / `$COMMIT_SHA` | Built-in | The triggering commit, short or full form |
| `$BRANCH_NAME` / `$TAG_NAME` | Built-in | The Git ref that triggered the build |
| `$TRIGGER_NAME` | Built-in | Which trigger configuration fired |
| `$_REGION`, `$_ENVIRONMENT`, any `_`-prefixed name | User-defined | Whatever the trigger config or YAML declares |

> [!TIP]
> **Best Practice**: always tag the built image with `$SHORT_SHA` (or `$COMMIT_SHA`), never `latest`, as shown above. An image tagged `latest` gives you no way to answer "which commit is actually running in production right now" — a question you will need to answer under real incident pressure, not just in a design review.

## Triggers: What Starts a Build

A trigger is the binding between a source event and a build config — without one, `cloudbuild.yaml` is just an inert file sitting in a repository. Cloud Build supports several trigger event types, and picking the right one per pipeline stage is a real design decision, not a default to leave alone.

| Trigger type | Fires on | Typical use |
|---|---|---|
| Push to branch | A commit lands on a matching branch (regex-filterable) | CI on every `main` commit |
| Pull request | A PR is opened/updated against a target branch | Run tests before merge, without pushing a deployable artifact |
| Tag push | A Git tag matching a pattern is pushed | Cutting a release build separately from every-commit CI |
| Manual | A human or API call explicitly starts it | An on-demand rebuild, a manual promotion trigger |
| Webhook | An arbitrary external HTTP call | Integrating a non-Git event source into the pipeline |
| Pub/Sub | A message on a specified topic | Chaining builds together, or triggering from a non-source-control event |

🔍 **From the Trenches**: A team configured a single Cloud Build trigger to fire on push to *any* branch, intending it as a convenience so feature-branch commits got CI coverage too. Six weeks later, a contractor pushed forty small commits to a long-lived experimental branch over one afternoon while debugging locally, and the resulting forty separate builds — each one pulling dependencies, running the full test suite, and pushing an image to Artifact Registry — burned through the team's monthly Cloud Build budget in a single day and pushed forty untagged, never-to-be-used images into the shared registry. The two-levels-deep lesson: the surface symptom was "budget alert fired unexpectedly," the immediate cause was an unfiltered branch trigger, and the underlying condition was that nobody had actually decided *which* branches warranted a full build-and-push versus a lighter test-only check — the trigger config reflected "make CI happen somehow," not a deliberate pipeline design.

> [!TIP]
> **Best Practice**: use a branch-name regex filter (`^main$`, or `^release/.*$`) on push triggers that build and push artifacts, and reserve pull-request triggers — which can run tests without pushing anything — for feature-branch coverage. This is exactly the mistake in the callout above, and the fix costs one line of trigger configuration.

## Artifact Registry: Where the Build's Output Lives

Artifact Registry is GCP's multi-format package registry — one product handling Docker images, Maven artifacts, npm packages, Python wheels, Go modules, and OS packages (Apt/Yum), rather than a separate product per artifact type. Three repository modes matter for a CI/CD pipeline specifically:

- **Standard repository** — the one used throughout this chapter so far: you push artifacts your own builds produce, and it stores them.
- **Remote repository** — a proxy-and-cache in front of an upstream public registry (Docker Hub, Maven Central, PyPI, npm). A build pulling a public dependency through a remote repository gets caching (faster, more resilient builds) and a single point where Artifact Analysis can scan cached upstream packages for known vulnerabilities before your build ever consumes them.
- **Virtual repository** — a single endpoint unifying several standard and/or remote repositories behind one URL, with priority ordering between upstreams to guard against dependency confusion (a malicious public package with the same name as an internal one). Vulnerability scanning is **not** supported inside a virtual repository itself — scanning happens at the standard/remote repositories it unifies, a real, easy-to-miss limitation worth knowing for the exam.

```mermaid
flowchart TB
    subgraph virtual["Virtual repo: meridian-deps"]
        direction LR
    end
    Standard["Standard repo:<br/>meridian-images<br/>(your own builds)"] --> virtual
    Remote["Remote repo:<br/>proxies Docker Hub<br/>(cached, scanned)"] --> virtual
    Build["Cloud Build step"] -->|"pulls FROM one URL"| virtual

    classDef own fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    classDef proxy fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    classDef unify fill:#f0e9fb,stroke:#6d43c0,color:#10161c
    class Standard own
    class Remote proxy
    class virtual unify
```

**What to notice**: a build never needs to know whether a given dependency actually lives inside Meridian's own registry or is being proxied from Docker Hub — the virtual repository's single URL hides that distinction, which is exactly what protects against a compromised or typo-squatted public package silently shadowing an intended internal one.

> [!WARNING]
> Automatic vulnerability scanning (via Artifact Analysis, covered in depth in Part 6) does not run inside a virtual repository. If your pipeline pulls dependencies exclusively through a virtual repo's single URL, confirm the *underlying* standard/remote repositories it unifies actually have scanning enabled — a virtual repo can silently give a false sense of coverage otherwise.

## Private Pools: Building Inside Your Own VPC

Cloud Build's default execution environment runs builds in a Google-managed pool with no network path into your VPC — fine for a build that only needs public internet access, but a real gap the moment a build step needs to reach a private resource: a Cloud SQL instance on a private IP, an internal artifact mirror, or anything behind a VPC Service Controls perimeter.

A **private pool** is a dedicated Cloud Build worker pool provisioned inside your own VPC, giving builds a real network identity and route into private infrastructure.

```yaml
# Reference a private pool from cloudbuild.yaml — the pool itself is
# provisioned separately (via gcloud builds worker-pools create), this
# just tells THIS build to run inside it instead of the shared default
options:
  pool:
    name: 'projects/meridian-cicd/locations/us-central1/workerPools/meridian-vpc-pool'
```

| Aspect | Default (shared) pool | Private pool |
|---|---|---|
| Network location | Google-managed, no VPC access | Runs inside your VPC, real internal IP |
| Reaches private Cloud SQL/on-prem | No | Yes |
| VPC Service Controls compatible | Only via public API access | Yes — can sit fully inside a perimeter |
| Cost model | Per build-minute, no idle cost | Per build-minute plus the pool's own provisioning |
| Setup | None | Provision the pool once, reference it per trigger/build |

## Designing a Pipeline: What CI Actually Needs to Verify

The exam guide's "designing pipelines" section (2.1) is explicitly about more than "run the tests" — a real CI stage earns its place in a pipeline by catching specific classes of problems before they reach a human reviewer or a later, more expensive stage.

**At minimum, a production CI pipeline should verify, in roughly this order (fail fast on the cheapest checks first):**

1. **Static checks** — linting, formatting, a type-checker — seconds, not minutes, and catches the highest volume of trivial issues before spending compute on anything heavier.
2. **Unit tests** — fast, no external dependencies, the bulk of test coverage.
3. **Build the artifact** — compile/package; a build failure here is a real signal, not a flaky test.
4. **Integration tests** — against real (or realistic, containerized) dependencies; slower, so run after the artifact is known to build cleanly.
5. **Vulnerability scan** — Artifact Analysis against the just-built image, covered in depth in Part 6.
6. **Push to Artifact Registry** — only after every prior gate passes.

```mermaid
flowchart TD
    Lint["Lint / static checks<br/>~seconds"] --> Unit["Unit tests<br/>~1-2 min"]
    Unit --> Build["Build artifact"]
    Build --> Integ["Integration tests<br/>~several min"]
    Integ --> Scan["Vulnerability scan"]
    Scan --> Push["Push to Artifact Registry"]

    Lint -.->|"fail"| Stop1["Build fails, fast"]
    Unit -.->|"fail"| Stop2["Build fails"]
    Integ -.->|"fail"| Stop3["Build fails"]
    Scan -.->|"critical CVE"| Stop4["Build fails,<br/>image never pushed"]

    classDef ok fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    classDef fail fill:#fbe8e6,stroke:#b3261e,color:#10161c
    class Lint,Unit,Build,Integ,Scan,Push ok
    class Stop1,Stop2,Stop3,Stop4 fail
```

💡 **The transferable insight**: this ordering is the same "fail cheap and fast before failing expensive and slow" discipline manufacturing lines apply with early inline quality checks before a part ever reaches final assembly — catching a defect at the first station is far cheaper than discovering it after the whole product is built.

## Monorepos: Path-Filtered Triggers Instead of One Trigger Per Service

Meridian's `orders` database access layer, `shipment-api`, and the GPS ingestion pipeline all live in one Git repository — a monorepo, chosen deliberately so a shared library change and its consuming services get reviewed together. A naive push trigger covering the whole repository would rebuild and retest *every* service on *every* commit, even a one-line change to a service nobody touched — directly repeating the wasted-build-minutes problem from this chapter's earlier From-the-Trenches callout, just with a different root cause.

Cloud Build's triggers support `includedFiles`/`ignoredFiles` path filters specifically for this: a trigger only fires when the pushed commit actually touches a matching path.

```bash
# shipment-api's trigger only fires when a commit touches its own
# directory or the shared library it depends on -- a commit that only
# touches gps-ingestion/ never wakes this trigger up at all
gcloud builds triggers create github \
  --project=meridian-cicd \
  --name=shipment-api-monorepo \
  --repo-name=meridian-platform \
  --repo-owner=meridian-logistics \
  --branch-pattern="^main$" \
  --build-config=services/shipment-api/cloudbuild.yaml \
  --included-files="services/shipment-api/**,libs/shared-auth/**"
```

```mermaid
flowchart TD
    Commit["Commit touches:<br/>services/shipment-api/handler.go"] --> Check1{"Matches shipment-api's<br/>includedFiles pattern?"}
    Check1 -->|"Yes"| Fire1["shipment-api trigger fires"]
    Check1 -.->|"gps-ingestion trigger:<br/>no match"| Skip1["gps-ingestion trigger<br/>does NOT fire"]

    classDef fire fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    classDef skip fill:#eaeef1,stroke:#c3ccd4,color:#10161c
    class Fire1 fire
    class Skip1 skip
```

**What to notice**: each service in the monorepo has its *own* trigger with its own path filter, rather than one trigger with branching logic inside a single `cloudbuild.yaml` — this keeps each service's build config independently reviewable and matches the "one trigger, one clear responsibility" shape this chapter's trigger-type table already established.

> [!TIP]
> **Best Practice**: always include a repo's genuinely shared libraries in every dependent service's `includedFiles` pattern, as shown above with `libs/shared-auth/**`. A monorepo's most common CI gap isn't over-triggering — it's *under*-triggering: a shared library change that silently doesn't retrigger a consuming service's tests, because its path filter only covers that service's own directory.

## Third-Party Tooling: Cloud Build Is Not GCP-Exclusive

The PCDE exam guide explicitly names "widely used third-party tooling (e.g., Git, Jenkins, Argo CD, Packer, kpt)" as fair game — a real signal that the certification tests CI/CD *concepts*, not just Google's own product surface. A few concrete ways this shows up in practice:

- **Jenkins** can trigger Cloud Build jobs via its API, or run entirely independently with GCP as just a deploy target — common in organizations mid-migration from an existing Jenkins investment.
- **Packer** builds machine images (VM images, not containers) and commonly runs as a Cloud Build step, producing a golden image that a later Terraform `apply` references.
- **kpt** manages Kubernetes configuration packages declaratively, often alongside or instead of raw Kustomize, and integrates with Config Sync from Part 2.
- **Argo CD** is a popular self-hosted alternative to Config Sync for Kubernetes GitOps — the exam's tool-agnostic framing means you're expected to recognize *when* Argo CD's model fits (multi-cloud Kubernetes fleets not exclusively on GKE, an existing Argo CD investment) versus when Config Sync's tighter GKE-native integration wins.

> [!NOTE]
> Don't over-rotate toward memorizing every third-party tool's flag syntax — the exam tests whether you recognize a tool's *role* in a pipeline (a build trigger, a manifest renderer, a GitOps reconciler) and can reason about tradeoffs, not vendor-specific trivia for tools GCP doesn't itself ship.

## A Full Worked Pipeline: Meridian's `shipment-api` Build

Bringing this chapter's pieces together — the actual trigger and build configuration Priya's team runs today, incorporating the branch filtering lesson from the earlier From-the-Trenches callout and the fail-fast ordering from the design section above.

```bash
# Create the push trigger — filtered to main only, per the lesson
# earlier in this chapter, running in the tooling project's private
# pool so integration tests can reach the staging Cloud SQL instance
# over a private IP
gcloud builds triggers create github \
  --project=meridian-cicd \
  --name=shipment-api-main \
  --repo-name=shipment-api \
  --repo-owner=meridian-logistics \
  --branch-pattern="^main$" \
  --build-config=cloudbuild.yaml \
  --substitutions=_REGION=us-central1
```

```yaml
# cloudbuild.yaml — the fail-fast ordering from this chapter's design
# section, expressed as real steps
steps:
  - id: 'lint'
    name: 'golangci/golangci-lint:latest'
    args: ['run', './...']

  - id: 'unit-tests'
    name: 'golang:1.24'
    entrypoint: 'go'
    args: ['test', '-short', './...']
    waitFor: ['lint']

  - id: 'build-image'
    name: 'gcr.io/cloud-builders/docker'
    args: ['build', '-t', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/meridian-images/shipment-api:${SHORT_SHA}', '.']
    waitFor: ['unit-tests']

  - id: 'integration-tests'
    name: 'golang:1.24'
    entrypoint: 'go'
    args: ['test', '-run', 'Integration', './...']
    env:
      - 'DB_HOST=10.10.0.5'  # private IP, reachable only via the private pool
    waitFor: ['build-image']

  - id: 'push-image'
    name: 'gcr.io/cloud-builders/docker'
    args: ['push', '${_REGION}-docker.pkg.dev/${PROJECT_ID}/meridian-images/shipment-api:${SHORT_SHA}']
    waitFor: ['integration-tests']

options:
  pool:
    name: 'projects/meridian-cicd/locations/us-central1/workerPools/meridian-vpc-pool'
  logging: CLOUD_LOGGING_ONLY

images:
  - '${_REGION}-docker.pkg.dev/${PROJECT_ID}/meridian-images/shipment-api:${SHORT_SHA}'
```

🧪 **Hands-on checkpoint**: note the explicit `waitFor` on every step — without it, Cloud Build runs steps with no declared dependency *in parallel* by default, which would let `integration-tests` start against an image `build-image` hasn't finished producing yet. Confirm your own build config's dependency chain is explicit rather than relying on steps happening to be listed in the right order.

## Common Mistakes and Interview Traps

| Mistake | Why it's wrong | What to say/do instead |
|---|---|---|
| Tagging every built image `latest` | No way to trace which commit is actually deployed during an incident | Tag with `$SHORT_SHA`/`$COMMIT_SHA` on every build |
| An unfiltered push trigger on every branch | Burns budget and registry space on throwaway feature-branch churn | Filter push triggers to `main`/`release/*`; use PR triggers for feature-branch coverage |
| Assuming a virtual repository is scanned | Artifact Analysis does not scan inside virtual repositories | Confirm scanning on the underlying standard/remote repos it unifies |
| Relying on step order alone for dependencies | Steps with no explicit `waitFor` can run in parallel | Declare `waitFor` explicitly for every real dependency between steps |
| Running every build in the default shared pool | Can't reach private Cloud SQL, on-prem systems, or a VPC-SC-perimetered resource | Provision a private pool for any build needing private network access |
| Treating vulnerability scanning as a nice-to-have | A shipped critical CVE is a real production risk, not a compliance checkbox | Fail the build on critical/high findings before the image is pushed (Part 6 goes deeper) |

## Worked Practice Problems

**Problem 1**: A `cloudbuild.yaml` has three steps with no `waitFor` fields, and a teammate insists this is fine because "they're listed in the right order." What's actually true about execution order here, and why might this be a real bug rather than a style nitpick?

*Answer*: Without an explicit `waitFor`, Cloud Build does not guarantee sequential execution by list order — steps with no declared dependency chain can run in parallel. If step 2 depends on an artifact step 1 produces (a compiled binary a later step copies into a Docker image, for instance) and both happen to run concurrently, the build can intermittently fail — or worse, intermittently *succeed* with a stale artifact — depending on timing. This is a real correctness bug, not a style preference, and the fix is declaring `waitFor` on every step with a real dependency.

**Problem 2**: A team wants their CI pipeline to reach a Cloud SQL instance over its private IP for integration tests, but every build currently runs in Cloud Build's default pool and integration tests fail with a connection timeout. What's the fix, and what's one cost tradeoff it introduces?

*Answer*: Provision and reference a private pool inside the same VPC as the Cloud SQL instance — the default shared pool has no route into any customer VPC at all, so no firewall rule or IP allowlisting can fix a default-pool build's private connectivity; the pool itself has to change. The tradeoff is cost and operational overhead: a private pool has its own provisioning cost on top of per-build-minute charges, and the team now owns keeping that pool's own networking (subnet sizing, firewall rules) correctly configured going forward.

**Problem 3**: A pipeline pulls its base Docker image through a virtual Artifact Registry repository that unifies a standard repo and a Docker Hub remote repo. A security review asks to confirm vulnerability scanning covers every image the pipeline consumes. What should you check, and why isn't "the virtual repo has scanning enabled" a valid answer?

*Answer*: Scanning is not supported inside a virtual repository itself — the correct check is whether the *underlying* standard and remote repositories the virtual repo unifies each have Artifact Analysis scanning enabled independently. "The virtual repo has scanning enabled" isn't a coherent answer to give in this review, because that setting doesn't exist at the virtual-repo level at all; the security review needs to trace through to the constituent repos.

## Summary and What's Next

This chapter covered the "CI" half of CI/CD in full depth: Cloud Build's container-per-step execution model, the substitution variables that keep a build config reusable, the trigger types and the branch-filtering discipline that keeps them from becoming a cost and clutter problem, Artifact Registry's three repository modes and where vulnerability scanning does and doesn't apply, private pools for reaching VPC-internal resources, and a fail-fast pipeline design ordering that catches cheap problems before expensive ones. Meridian's `shipment-api` build ties every piece together into one real, working configuration.

**Part 4** picks up exactly where this chapter's pipeline ends — the image sitting in Artifact Registry — and covers Cloud Deploy: how that artifact actually gets promoted through dev, staging, and production with canary rollouts, approval gates, and automated rollback.
