GitHub Actions provide workflows for Continuous Integration (CI). They can be invoked from various triggers, but this article specifically focuses on pull requests. By running automation against each commit, contributors gain confidence in their code. The setup is simple, easily integrated, and lives alongside the code.
💡 If you are looking to setup GitHub actions with TestProject, here‘s a nice tutorial on how to achieve that for your Python automation within your CI/CD flow.
A Simple Example
name: pull-request on: pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest steps: - name: Check out code - uses: actions/checkout@v2 - name: Set up node uses: actions/setup-node@v1 - name: Install dependencies run: npm install - name: Run tests run: npm test
Analyzing Each Segment
on:
has child properties that describe triggers for when the action will execute.pull-request:
makes the action execute against pull requests as they are open, and for each commit added to them. There are other options, such as on push, that run against different events.branches: [ main ]
limits execution to pull requests targeting themain
branch. This prevents accidentally running actions against experimental or temporary branches. If removed, the action runs against all branches.jobs:
lists the workflows that will run against the commit that triggered the action. Jobs execute in parallel, which will be explored later in this article.build:
is the name of the job, which will be displayed as output in the pull request. It is not a special property in this case—it could be named banana.runs-on: ubuntu-latest
is the virtual machine (VM) where the code will execute.steps:
lists the steps of the action, from setting up to running tests. Each one runs sequentially.
Pull Request Visualization
The only requirement for this workflow to appear in the pull request is for the YAML file to exist in the <root>/.github/workflows/
directory. GitHub will automatically execute the workflow when a pull request is opened and show the UI’s progress.
Forcing Builds to Pass
A build rule can prevent merging pull requests when the action fails.
To enable it, administrators can perform the following steps:
- Navigate to Settings -> Branches.
- Click the Add rule button.
- Check the box for the action created in the previous steps
Enable the “Require status checks to pass before merging” option. Then select the appropriate action.
Using Services
Sometimes integration and end-to-end test require third-party, external services to run. These prerequisites can be defined in the action YAML under the services
section.
name: pull-request on: pull_request: branches: [ main ] jobs: build: runs-on: ubuntu-latest services: my-postgres-container: image: postgres env: POSTGRES_PASSWORD: postgres steps: - name: Check out code - uses: actions/checkout@v2 - name: Set up node uses: actions/setup-node@v1 - name: Install dependencies run: npm install - name: Run tests run: npm test env: POSTGRES_HOST: my-postgres-container POSTGRES_PASSWORD: postgres
Optimizing
The example above is sufficient for small projects. As demonstrated, GitHub actions are simple to set up and get going. However, with a large enterprise repository, certain tricks can speed up the build.
Using Separate Jobs
Separate jobs take longer but give faster feedback. For example, separating the unit tests from the integration tests actually increases the build time.
This change may seem counter-intuitive but can provide benefits:
- The unit-test job can run faster because it doesn’t need to set up any services or dependencies.
- The developer is notified of quick failures faster. GitHub still runs all of the jobs, providing complete results.
- The builds run in parallel, so end-to-end tests do not need to wait for the integration tests to complete.
Caching
Caching can help preserve data from one build to the next, reducing the execution time. The following example caches the node_modules folder, which reduces the time needed to install the dependencies.
jobs: unit-tests: runs-on: ubuntu-latest steps: - name: Check out source code uses: actions/checkout@v2 - name: Set up node uses: actions/setup-node@v1 - name: use cache uses: actions/cache@v2 with: path: | node_modules */*/node_modules key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies run: npm install - name: Run unit tests run: npm run test:unit
The hashFiles
function is how the cache key becomes unique, determined from the contents of the file. If the file doesn’t change, the function generates the same hash and the next step uses the cache.
Matrix Strategy
By using a matrix, the action can re-use the same job template to perform tests in multiple environments.
jobs: integration-tests: strategy: matrix: node_version: ['10', '12'] os: [ubuntu-latest, windows-latest, macOS-latest] runs-on: ${{ matrix.os }} steps: - name: Check out source code uses: actions/checkout@v2 - name: Set up node uses: actions/setup-node@v1 with: node-version: ${{ matrix.node_version }} - name: use cache uses: actions/cache@v2 with: path: | node_modules */*/node_modules key: ${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('**/package-lock.json') }} - name: Install dependencies run: npm install - name: Run unit tests run: npm run test:integration
This workflow will create six parallel jobs, running on different node and operating system versions. The referenced matrix variables set the node version, and generate unique cache keys. Forgetting to tie the cache to the node or operating system version could lead to issues that are difficult to diagnose.
Community Action Options
In some cases, like when the example uses actions/checkout@v2
, using shared actions can save time and effort. Be sure to check for special options.
- name: Check out source code uses: actions/checkout@v2 with: fetch-depth: 1
The fetch-depth
option can prevent wasted time from checking out source code history. In most cases, the history does not make a difference to whether the tests pass.
💡 This option is especially valuable in old repositories or monorepos.
Sharing Long-running Steps
jobs: long-running-test-dependency: runs-on: ubuntu-latest steps: - name: Long-running task run: do-the-thing.sh - name: use cache uses: actions/cache@v2 with: path: "/output" key: ${{ runner.os }}-$GITHUB_RUN_ID unit-tests: needs: long-running-test-dependency runs-on: ubuntu-latest steps: - name: use cache uses: actions/cache@v2 with: path: "/output" key: ${{ runner.os }}-$GITHUB_RUN_ID - name: run tests runs: npm test:unit integration-tests: needs: long-running-test-dependency runs-on: ubuntu-latest steps: - name: use cache uses: actions/cache@v2 with: path: "/output" key: ${{ runner.os }}-$GITHUB_RUN_ID - name: run tests runs: npm test:integration
The needs
keyword sets up a dependency chain. As a result, the two testing jobs do not execute until the first job is complete. They both use the cache instead of duplicating the effort, and the GITHUB_RUN_ID
variable ensures it only lives for this job. GitHub also has an option to upload and download artifacts between jobs.
Conclusion
GitHub Actions are powerful and simple with the right configuration. They provide quick and reliable feedback integrated directly into pull requests.