Generating CI Workflows During Scaffold
Every project scaffoldr creates in v0.1.0 gets a working CI setup out of the box - tests and linting run automatically on every push and pull request. No separate step, no manual GitHub Action setup.
The workflow file
github_actions_ci builds the YAML content using an f-string with the project's values filled in:
def github_actions_ci(project_name: str, python_version: str) -> str:
return f"""\
name: CI
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
jobs:
test:
name: Test (Python ${{{{ matrix.python-version }}}})
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["{python_version}"]
"""
on defines what triggers the workflow - here, any push or PR targeting main. jobs.test is the first of two jobs. runs-on: ubuntu-latest tells GitHub Actions which operating system to run the job on. The worflow executes on a temporary virtual machine running the newest available Ubuntu image (ubuntu-latest) that GitHub spins up for each run.
Escaped braces
The double curly braces - ${{{{ matrix.python-version }}}} - look unusual if you haven't seen this pattern before. GitHub Actions uses ${{ }} for its own variable interpolation, and Python's f-strings also use {} for their own interpolation. To write a literal { or } inside an f-string, you double it - {{ becomes a single { in the output. So ${{{{ ... }}}} in the f-string source becomes ${{ ... }} in the actual YAML file, which is what GitHub Actions expects.
Setting up the test job
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{{{ matrix.python-version }}}}
uses: actions/setup-python@v5
with:
python-version: ${{{{ matrix.python-version }}}}
- name: Configure git identity for tests
run: |
git config --global user.email "ci@{project_name}.com"
git config --global user.name "CI"
actions/checkout@v4 pulls the repo's code into the runner (temporary virtual machine) - without this, there's nothing to test. actions/setup-python@v5 installs the Python version from the matrix.
The git identity step comes from copying scaffoldr's own CI config into the template. Git commits fail without a configured identity. scaffoldr's test suite calls git commit as part of testing the scaffold function, which is why its CI needs this step. Most generated projects won't run git commands in their own tests. This step shouldn't have been in the default - a user whose tests need git should have added it themselves by editing the generated ci.yml. Instead, it ships by default in v0.1.0.
Running tests and linting
- name: Install dependencies
run: |
pip install -e ".[dev]"
pip install ruff pytest
- name: Run tests
run: pytest tests/ -v
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "{python_version}"
- name: Install ruff
run: pip install ruff>=0.4.0
- name: Run ruff
run: ruff check .
pip install -e ".[dev]" installs the project along with its dev dependencies. The -e flag means editable mode. Editable mode links the package directly to source files on disk, instead of copying them. This is useful during local development, where you're running code you're actively editing - changes to the source are picked up immediately, without reinstalling. CI checks out the code once and runs it once. There's no source editing happen during that run, so editable mode has no effect here. This was likely a habit carried over from local development, not a deliberate CI choice.
lint runs as a separate job from test, not a step inside it. Both jobs run in parallel on GitHub Actions by default, so linting doesn't wait for tests to finish, and a lint failure doesn't hide behind a test failure or vice versa - the CI output shows exactly which one failed independently.
What's next
That's every file scaffold writes and every API call scaffoldr new makes. The next post is a retrospective on v0.1.0 - what worked, what didn't, and what's changing in v0.2.0.
The code is on GitHub.
