Workshop: Continuous Integration #
In this document, we will introduce continuous integration (CI) and walk through setting up a simple CI pipeline for an Android project using GitHub Actions.
Introduction #
Continuous integration means integrating code changes into a shared main branch frequently (often many times per day) and verifying each integration with an automated build and tests. Everyone on the team pushes to the same main branch regularly, so changes are visible and conflicts are surfaced early. The goal is to avoid the pain of a long, chaotic integration phase at the end of a release; instead, each integration is small and easier to debug.

This release pipeline diagram shows how a software project moves from code to users. CI focuses on the first two steps: Integrate that merges the code changes into the main branch, and Build compiles, produces, and verifies the artifacts. Release engineering and deployment strategies (covered in the Release Engineering document) build on this.
To motivate why we need CI, here is a comparison of different integration styles, where you can see how often you integrate affects how painful integration is:
- Pre-release integration: Components are implemented and tested individually (with unit tests). Integration happens once after all features are done, often in a dedicated integration phase. That phase can become chaotic and time-consuming, because many changes collide at once.
- Feature branches: Each developer pulls from the mainline, implements a feature on a branch, then merges. Integration happens on each merge (more frequently than pre-release integration) but still in discrete steps.
- Continuous integration: Developers pull and push changes continuously (e.g. every day or several times a day). Integration happens very frequently, so each integration is smaller and easier to fix.
Next we will discuss how to adopt CI: some best practices and some (open-source) tools around there.
CI best practices #
Here are a number of best practices (or pre-conditions that the codebase / development team should setup) for CI to be effective. Acknowledgements: this list is largely extracted from Martin Fowler’s article on Continuous Integration.
- Automate the build. The build should run from a single command (e.g.
./gradlew build) so that anyone and any CI server can easily reproduce it. Include tests in that build so that passing the build means “compiles and tests pass.” - Keep the build fast. If the build takes too long, people will avoid running it or will start developing the next feature without waiting for CI signals. Fast feedback encourages frequent integration.
- Every push to the main branch should trigger a build. As soon as someone pushes (or opens a pull request), CI should run the build and tests. That way you know immediately if the build is broken.
- Fix broken builds immediately. A failed build blocks everyone; the team should treat fixing it as top priority. Fix forward (fix the code or revert the change) rather than leaving the main branch broken.
- Automate deployment when appropriate. After the build and tests pass, you can automate deployment to a test or staging environment; this is sometimes referred to as continuous delivery, put to gether with continuous integration as CI/CD.
CI platforms and tools #
The major code repository hosting platforms provide their built-in CI features, which is generally free for open-source/public repositories:
There are also other CI platforms not bounded to a specific code repository hosting platform:
These platforms usually require developers to specify the CI configuration (the commands to run for the build and tests), usually in some YAML file in the repository (e.g., .github/workflows/*.yml for GitHub Actions).
Then the platform will run the commands on their servers when triggered by the events you specify (e.g., push, pull request to main branch).
Even better, there are plenty of community-maintained CI configuration templates for popular languages and frameworks that you can use as a starting point (e.g., GitHub Actions templates).
If you are interested in self-hosting CI servers, there are also open-source tools to help you implement CI (monitoring push / pull request events and running the build and tests accordingly):
Demo: GitHub Actions for the Android demo #
The demo we will be following is the cs446-1261-demos repository.
The CI configuration runs the build and tests for the TestingDemo project (the same Tip Calculator app used in the Testing workshop).
You can fork the repo, push a change or open a pull request to main, and watch the workflow run on GitHub.
CI configuration #
The CI configuration is defined in .github/workflows/android.yml. GitHub Actions runs any workflow file under .github/workflows/ based on the events you specify (e.g. push, pull request).
Breaking down the parts of the CI configuration:
name: Android CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
This part configures CI to run on every push and every pull request to main branch. Then we have two jobs:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
working-directory: TestingDemo
- name: Build with Gradle
run: ./gradlew build
working-directory: TestingDemo
The build job compiles the app and runs the unit tests, via a simple gradle command.
Typically, the job starts from a clean container thus setup steps need to be done, only a few for the gradle command: checkout the repo, setup JDK, and grant execute permission for the gradlew script.
ui-tests:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
working-directory: TestingDemo
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run UI tests on emulator
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 30
target: google_apis
arch: x86_64
script: ./gradlew connectedDebugAndroidTest
working-directory: TestingDemo
The ui-tests job runs the UI tests, and depends on the build job (UI tests is more costly, so we only run it if the unit tests pass).
It spins up an Android emulator using android-emulator-runner; the KVM step enables hardware acceleration on Ubuntu runners so the emulator boots faster.
Viewing workflow runs #
After a push or pull request, you can see the run and its logs under the Actions tab on GitHub:

If the build or tests fail, the logs indicate which step failed and why. You can also see the build status on the commit or pull request (e.g. a green check or red X next to the commit).