CI/CD best practices

Kenny DuMez
Kenny DuMez
Graphite software engineer

Continuous integration (CI) and continuous deployment (CD) are important practices in modern software development that promote frequent code integration and automated deployment. Implementing CI/CD effectively can significantly enhance the speed, reliability, and security of your development process. This guide will cover best practices for CI/CD with a focus on using GitHub Actions, providing detailed examples to illustrate how to optimize your workflows.

CI/CD is a methodological approach in software development that involves automatically integrating code from multiple contributors into a shared repository multiple times a day (CI), and automatically delivering or deploying code to production with minimal manual intervention (CD).

  • Automate everything: Automation is the backbone of CI/CD. It reduces human error, standardizes feedback loops, and speeds up processes.
  • Maintain a code repository: All production code should be stored in a version-controlled repository.
  • Keep the build fast: Ensuring that the build and feedback loop is quick helps in identifying issues early and keeps the development process agile.
  • Test automation: Write tests to cover expected behavior and edge cases to ensure that integration does not break functionality.
  • Frequent commits: Push code to the repository frequently to decrease the complexity of merges and increase collaborative potential.
  • Transparent and accessible results: Ensure that the results of builds and deployments are visible to the team.

GitHub Actions makes it easy to automate all your software workflows, now with world-class CI/CD. Build, test, and deploy your code right from GitHub.

Here's a simple example of a CI workflow using GitHub Actions that demonstrates how to set up a job to install dependencies, run tests, and build your code:

Terminal
name: CI Workflow
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: '14'
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test
- name: Build
run: npm run build

This workflow triggers on push or pull request events to the main branch. It checks out the code, sets up Node.js, installs dependencies, runs tests, and builds the project.

To test across multiple operating systems or versions of a programming language, you can use a matrix strategy:

Terminal
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [12, 14, 16]
steps:
- uses: actions/checkout@v2
- name: Set up Node.js
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node }}
- name: Install dependencies
run: npm install
- name: Run tests
run: npm test

Matrix builds in continuous integration allow you to automatically run tests across various combinations of operating systems and programming language versions. In this example, the test job is configured to run on three different operating systems (Ubuntu, Windows, and macOS) and with three versions of Node.js (12, 14, 16). Each combination is automatically set up and tested, making it easier to identify system-specific issues and ensure compatibility across different environments.

For CD, you can define environments in GitHub Actions to handle different stages like staging or production:

Terminal
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
needs: build
steps:
- uses: actions/checkout@v2
- name: Deploy to production
run: ./deploy.sh

In this configuration, the job named "deploy" is set to run only after a successful build (needs: build) and is specifically targeted to the "production" environment. This approach allows for controlled deployments, where the deployment script (deploy.sh) is executed on a runner with the latest Ubuntu version, ensuring that code transitions smoothly from development to production with the appropriate checks and balances in place.

In GitHub Actions CI environments, tools like the Graphite CI optimizer help reduce costs by effectively managing when and how CI jobs are triggered, especially when stacking pull requests.

  1. Selective CI triggering: Graphite allows you to customize CI workflows to only run necessary jobs. By using the Graphite CI optimizer, you can specify that only certain pull requests in a stack trigger CI runs. This is particularly useful when you have multiple dependent PRs stacked on top of each other, where only the latest PR might need the full CI run, or only running CI once other specific conditions are met.

  2. Conditional job execution: With the YAML configurations provided by Graphite, GitHub Actions can be set to execute jobs only if the previous optimization step determines it’s necessary. This is achieved using conditions in the workflow that check the output from the Graphite optimizer. If the optimizer decides a job doesn’t need to run (perhaps because the changes do not affect critical parts of the codebase), the subsequent jobs are skipped.

    Terminal
    jobs:
    optimize_ci:
    runs-on: ubuntu-latest
    outputs:
    skip: ${{ steps.check_skip.outputs.skip }}
    steps:
    - name: Optimize CI
    id: check_skip
    uses: withgraphite/graphite-ci-action@main
    with:
    graphite_token: ${{ secrets.GRAPHITE_CI_OPTIMIZER_TOKEN }}
    your_first_job:
    needs: optimize_ci
    if: needs.optimize_ci.outputs.skip == 'false'
    ...

In this example workflow, the optimize_ci job determines whether subsequent CI jobs should run by using the Graphite CI optimizer. The outcome of this decision is based on the changes within the pull request and is communicated through the skip output, which subsequent jobs check before running. If the optimize_ci job sets skip to true, indicating no CI is necessary, the your_first_job will not execute, thus optimizing resource usage and reducing unnecessary CI runs.

  1. Integration and customization: Integrating the Graphite CI optimizer’s with GitHub Actions is as simple as setting up a new job at the beginning of your workflow that decides whether the rest of the CI jobs should proceed. This job checks the necessity of running further jobs based on the current PR’s context and changes, allowing you to prevent redundant CI runs which would otherwise increase cost.

  2. Error handling and reliability: The CI optimizer is designed to "fail open," meaning if there’s an issue with the optimization process, it defaults to running the CI to ensure that critical checks and builds are not inadvertently skipped. This helps maintain reliability without compromising on cost savings by avoiding unnecessary runs.

  3. Efficient use of CI minutes: Since GitHub Actions pricing often revolves around the usage of CI minutes, reducing the number of unnecessary CI runs directly translates to lower costs. By optimizing which jobs run and when, Graphite helps conserve CI minutes, thus reducing the overall expenses related to the CI process.

  • Reuse workflows: Use reusable workflows to avoid duplication and maintain consistency.
  • Secure secrets: Use GitHub Secrets to manage sensitive information like API keys or credentials.
  • Monitor and optimize: Regularly review the execution time and costs associated with your workflows. Optimize where necessary.

By following these best practices and leveraging tools like GitHub Actions, teams can build robust CI/CD pipelines that streamline development processes, enhance collaboration, and accelerate time to market.

For further reading leveraging GitHub Actions in your CI/CD workflows, see the official GitHub Actions docs.

Git inspired
Graphite's CLI and VS Code extension make working with Git effortless.
Learn more

Graphite
Git stacked on GitHub

Stacked pull requests are easier to read, easier to write, and easier to manage.
Teams that stack ship better software, faster.

Or install our CLI.
Product Screenshot 1
Product Screenshot 2