Skip to main content

Command Palette

Search for a command to run...

Generating CI Workflows During Scaffold

Updated
•4 min read•View as Markdown
B
I build CLI tools and developer productivity scripts in Python and Rust.

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.

Building scaffoldr v0.1.0

Part 7 of 7

A behind-the-scenes look at building scaffoldr - a CLI tool that scaffolds new Python projects with GitHub integration. This series walks through the local scaffolding engine, GitHub API integration, automated issue creation, branch protection, and CI generation - the core features that shipped with v0.1.0.

Start from the beginning

I kept forgetting things at project setup - so I built scaffoldr

Everytime I created a new project, I'd go through the same ritual. Create the repo, clone it, set up the directory structure, write the initial pyproject.toml, configure CI, add branch protection, cre