Skip to content

VOL 06 / CH 06 / LESSON 01

6.1 Continuous Integration, Reproducible Builds, and Pipeline Security ​

Continuous integration (CI) means frequently merging small changes into a shared mainline and using automated builds to detect conflicts and regressions. The test server supports that feedback loop. The longer a branch exists without integration, the later the feedback loop becomes, and the greater the risk of accumulated merge issues.

How CI Shortens Feedback Loops ​

A single change must go through at least the following stages:

text
Local quick check
  -> Commit/push
    -> Rebuild in an isolated environment
      -> Compile, static analysis, tests
        -> Generate immutable artifacts
          -> Report results and control merge

It’s not necessary to run all tests after every keystroke. Fast checks should return clear feedback early. Expensive compatibility, performance, and security tests can run at merge, on a schedule, or for release candidates. The key is visible failures with clear ownership, and the main branch should recover to green as quickly as possible.

A Minimal GitHub Actions Validation Pipeline ​

This complete workflow belongs at .github/workflows/verify.yml in a Gradle Java project. It assumes a committed executable gradlew, wrapper JAR/properties, build files, and tests connected to check. The earlier Maven exercises do not contain that Gradle project; use mvn verify and Maven caching if adapting them. This workflow validates code and uploads failure reports; it does not publish a release artifact.

yaml
name: verify

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: verify-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  test:
    runs-on: ubuntu-24.04
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v6
        with:
          persist-credentials: false

      - uses: actions/setup-java@v5
        with:
          distribution: temurin
          java-version: '21'
          cache: gradle

      - name: Verify Gradle wrapper
        uses: gradle/actions/wrapper-validation@v6

      - name: Compile, analyze and test
        run: ./gradlew --no-daemon check

      - name: Upload test reports on failure
        if: failure()
        uses: actions/upload-artifact@v7
        with:
          name: test-reports
          path: '**/build/reports/tests/'
          retention-days: 7

The major-version tags were checked on 2026-09-05 and are retained for readability; they can still move. Production repositories should pin external actions to verified, full commit SHAs; GitHub documentation states that full SHAs provide immutable references. Use Dependabot or a similar mechanism to propose upgrade PRs, avoiding the extremes of "never upgrading" and "automatic drift on every commit."

permissions explicitly scopes down to contents: read, avoiding unnecessary write permissions. Only the release job gains packages: write, attestations: write, or id-token: write; do not broadly grant these permissions at the top level of the workflow.

Building Must Be Driven by Version-Controlled Inputs ​

A reproducible build lets another party recreate byte-identical specified artifacts from the same source, declared build environment, and instructions. This is stronger than “the build succeeds again.” It does not require arbitrary JDKs or CPU targets to produce identical bytes. Define the artifacts and environment first, then control:

  • JDK, build tool, and plugin versions;
  • Use of Gradle Wrapper or Maven Wrapper;
  • Locking and validation strategies for direct and transitive dependencies;
  • Differences in operating system, CPU architecture, and local tools;
  • Build time, random numbers, file ordering, and other non-deterministic inputs;
  • Versions of scripts downloaded from the network, images, and Actions.

Compare hashes from two clean build directories using the declared toolchain, with task-output caches disabled for the comparison. A matching test result is not this comparison. Archive timestamps, entry order, absolute paths, and generated build metadata can change bytes without changing behavior. Pinning only java-version: 21 or ubuntu-24.04 still allows patch-level runner/toolchain drift; preserve the resolved versions or build-image digest when claiming reproducibility.

Caching reduces repeated work; correctness must survive a cache miss. Even when cache misses occur, the pipeline must still succeed. Cached content must never bypass dependency validation.

PR Pipelines and Trusted Release Pipelines Must Be Segregated by Authority ​

Pull requests from forks contain code that contributors may modify. If the same job both executes this code and holds deployment credentials, malicious test scripts could potentially steal secrets.

Security boundaries should be:

text
PR Validation: Read source code + no production credentials + minimal token permissions
Mainline Build: Protected branch + minimal permissions to artifact repository
Deployment: Protected environment + identity federation or short-lived credentials + approval or policy gatekeeping

Do not transform untrusted PRs into pull_request_target and directly check out and execute their code. If privileged automation is absolutely required, split it into a controlled workflow that does not execute the PR's content, and carefully review the event semantics.

Use OIDC Instead of Long-Term Cloud Keys ​

GitHub Actions can request OIDC tokens with workflow identity claims, which can then be exchanged for short-lived credentials on cloud platforms. The following is a permissions fragment for the job that needs cloud identity. It still needs a cloud-side trust policy and a provider-specific login or credential-exchange step:

yaml
permissions:
  contents: read
  id-token: write

id-token: write allows requesting OIDC tokens but does not directly grant write permissions to cloud resources. Actual permissions are determined by cloud trust policies and should be constrained by repository, branch/environment, audience, and role. Arbitrary repositories or references must never be permitted.

Dependency Services: Choosing Between Realism and Speed ​

In CI pipelines, databases can be implemented in three common ways:

  • job service container: fast to start, suitable for fixed topologies;
  • Testcontainers: gives test code full control over the lifecycle, enabling consistency between local and CI environments;
  • remote shared environment: closer to a full production system, but introduces complexity in isolation, cost, and debugging.

Regardless of the approach, real migration must be performed, test data must be isolated, and target database version compatibility must be verified. Never substitute production-grade PostgreSQL with H2 in CI for convenience, and don’t defer dialect and transaction differences to production deployment.

Matrix Testing Covers Only Committed Combinations ​

A library project might commit to multiple JDKs and operating systems, requiring a matrix test setup; internal services typically only need to validate key production targets and upgrade candidates. The larger the matrix, the slower the feedback loop, so not every possible version should be included in every PR.

yaml
strategy:
  fail-fast: false
  matrix:
    java: ['21', '25']

This strategy fragment belongs under jobs.test. Change setup-java to java-version: ${{ matrix.java }} and give reports distinct names such as test-reports-${{ matrix.java }}; otherwise both jobs still use 21 and may collide when uploading. Configure the Gradle toolchain consistently, or a fixed toolchain can override the matrix JDK. State whether both versions are supported or one is an upgrade probe, and use a Wrapper version that runs on both JDKs.

Merge Gates and Emergency Handling ​

Protected mainlines typically require:

  • All required status checks to pass;
  • Necessary reviews to be completed;
  • No unverified differences between branches and mainline;
  • High-risk paths to be reviewed by code owners;
  • Strict limitations on permissions and auditing for bypassing gates.

When a mainline fails, prioritize rolling back or fixing the smallest, most recent change that caused the failure. Leaving failing tests permanently marked as "allowed to fail" risks turning CI gates into background noise.

The next section continues from "verification passes" to "build once, promote incrementally," and addresses database, canary, and rollback scenarios.

References ​

Built with VitePress | Software Systems Atlas