Compiled on: 2024-05-07 — printable version
The practice of integrating code with a main development line continuously
Verifying that the build remains intact
Traditionally, protoduction is jargon for a prototype that ends up in production
|
Plenty of technologies on the market
We will use GitHub Actions: GitHub integration, free for FOSS, multi-os OSs supported
Naming and organization is variable across platforms, but in general:
In essence, designing a CI system is designing a software construction, verification, and delivery pipeline with the abstractions provided by the selected provider.
Configuration can grow complex, and is usually stored in a YAML file
(but there are exceptions, JetBrains TeamCity uses a Kotlin DSL).
Workflows are configured in YAML files located in the default branch of the repository
.github/workflows/
folder.One configuration file $\Rightarrow$ one workflow
For security reasons, workflows may need to get manually activated in the Actions tab of the GitHub web interface.
Note: the GitHub Actions application is open source and can be installed locally, creating “self-hosted runners”. Self-hosted and GitHub-hosted runners can work together.
Upon their creation, runners have a default environment
Documentation available at https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners#preinstalled-software
Several CI systems inherit the “convention over configuration” principle.
GitHub actions does not adhere to the principle: if left unconfigured, the runner does nothing (it does not even clone the repository locally).
Minimal, simplified workflow structure:
# Mandatory workflow name
name: Workflow Name
on: # Events that trigger the workflow
jobs: # Jobs composing the workflow, each one will run on a different runner
Job-Name: # Every job must be named
# The type of runner executing the job, usually the OS
runs-on: runner-name
steps: # A list of commands, or "actions"
- # first step
- # second step
Another-Job: # This one runs in parallel with Job-Name
runs-on: '...'
steps: [ ... ]
Consider check.yml
file on the calculator
repository:
name: CI/CD
on:
push:
paths-ignore:
- '.gitignore'
- 'CHANGELOG.md'
- 'LICENSE'
- 'README.md'
pull_request:
workflow_dispatch:
jobs:
test:
strategy:
fail-fast: false
matrix:
os:
- ubuntu-latest
- windows-latest
- macos-latest
python-version:
- '3.10'
- '3.11'
- '3.12'
runs-on: ${{ matrix.os }}
name: Test on Python ${{ matrix.python-version }}, on ${{ matrix.os }}
timeout-minutes: 45
steps:
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install poetry
run: pip install poetry
- name: Checkout code
uses: actions/checkout@v4
- name: Restore Python dependencies
run: poetry install
- name: Test
shell: bash
run: poetry run python -m unittest discover -v -s tests
release:
needs: test
if: github.ref == 'refs/heads/master'
runs-on: ubuntu-latest
name: Release on PyPI and GitHub
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Install poetry
run: pip install poetry
- name: Restore Python dependencies
run: poetry install
- name: Bump version
shell: bash
run: poetry run python bump_version.py --apply | tee CHANGELOG.md
- name: Commit version change
shell: bash
run: |
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"
git add pyproject.toml
git commit -m "chore(release): v.$(poetry version --short) [skip ci]"
- name: Build Python Package
run: poetry build
- name: Push changes
run: git push
- name: Publish on TestPyPI
run: poetry publish --repository pypi-test --username __token__ --password ${{ secrets.TEST_PYPI_TOKEN }}
- name: Create GitHub Release
shell: bash
run: |
RELEASE_TAG=$(poetry version --short)
gh release create $RELEASE_TAG dist/* -t v$RELEASE_TAG -F CHANGELOG.md
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
By default, GitHub actions’ runners do not check out the repository
It is a common and non-trivial operation (the checked out version must be the version originating the workflow), thus GitHub provides an action:
- name: Check out repository code
uses: actions/checkout@v4
Since actions typically do not need the entire history of the project, by default the action checks out only the commit that originated the workflow (--depth=1
when cloning)
Also, tags don’t get checked out
Communication with the runner happens via workflow commands
The simplest way to create outputs for actions is to print on standard output a message in the form:
"{name}={value}"
and redirect it to the end of the file stored in the $GITHUB_OUTPUT
environment variable:
echo "{name}={value}" >> $GITHUB_OUTPUT
jobs:
Build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: danysk/action-checkout@0.2.16
- id: branch-name # Custom id
uses: tj-actions/branch-names@v8
- id: output-from-shell
run: ruby -e 'puts "dice=#{rand(1..6)}"' >> $GITHUB_OUTPUT
- run: |
echo "The dice roll resulted in number ${{ steps.output-from-shell.outputs.dice }}"
if ${{ steps.branch-name.outputs.is_tag }} ; then
echo "This is tag ${{ steps.branch-name.outputs.tag }}"
else
echo "This is branch ${{ steps.branch-name.outputs.current_branch }}"
echo "Is this branch the default one? ${{ steps.branch-name.outputs.is_default }}"
fi
Most software products are meant to be portable
A good continuous integration pipeline should test all the supported combinations*
The solution is the adoption of a build matrix
if
conditionalsjobs:
Build:
strategy:
matrix:
os: [windows, macos, ubuntu]
jvm_version: [8, 11, 15, 16] # Arbitrarily-made and arbitrarily-valued variables
ruby_version: [2.7, 3.0]
python_version: [3.7, 3.9.12]
runs-on: ${{ matrix.os }}-latest ## The string is computed interpolating a variable value
steps:
- uses: actions/setup-java@v4
with:
distribution: 'adopt'
java-version: ${{ matrix.jvm_version }} # "${{ }}" contents are interpreted by the github actions runner
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python_version }}
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby_version }}
- shell: bash
run: java -version
- shell: bash
run: ruby --version
- shell: bash
run: python --version
We would like the CI to be able to
Both operations require private information to be shared
Of course, private data can’t be shared
printenv
)How to share a secret with the build environment?
Secrets can be stored in GitHub at the repository or organization level.
GitHub Actions can access these secrets from the context:
secrets.<secret name>
context objectSecrets can be added from the web interface (for mice lovers), or via the GitHub API.
#!/usr/bin/env ruby
require 'rubygems'
require 'bundler/setup'
require 'octokit'
require 'rbnacl'
repo_slug, name, value = ARGV
client = Octokit::Client.new(:access_token => 'access_token_from_github')
pubkey = client.get_public_key(repo_slug)
key = Base64.decode64(pubkey.key)
sodium_box = RbNaCl::Boxes::Sealed.from_public_key(key)
encrypted_value = Base64.strict_encode64(sodium_box.encrypt(value))
payload = { 'key_id' => pubkey.key_id, 'encrypted_value' => encrypted_value }
client.create_or_update_secret(repo_slug, name, payload)
The sooner the issue is known, the better
$\Rightarrow$ Automatically run the build every some time even if nobody touches the project
cron
CI jobs if there is no action on the repository, which makes the mechanism less usefulThere exist a number of recommended services that provide additional QA and reports.
Non exhaustive list:
The Linux Foundation Core Infrastructure Initiative created a checklist for high quality FLOSS.
CII Best Practices Badge Program https://bestpractices.coreinfrastructure.org/en
A full-fledged CI system allows reasonably safe automated evolution of software
At least, in terms of dependency updates
Assuming that you can effectively intercept issues, here is a possible workflow for automatic dependency updates:
Bots performing the aforementioned process for a variety of build systems exist.
They are usually integrated with the repository hosting provider