In the last year I have developed a strong interest in simracing as a hobby. We are currently living in a golden age of simulated racing with many great sim titles to choose from, but my preferred one is the iRacing simulator.

Alongside iRacing’s official matchmaking races there exists a large and healthy ecosystem of community-operated league races. These leagues run the spectrum from extremely competitive to very, very casual. Toward the latter end of that spectrum is the 24 Hour of Lemons community, an iRacing league that accompanies the US-based motorsport series of the same name in which people race cars worth no more than $500 for as many laps as they can.

An aside: while I don’t harbor any serious real-world motorsport ambitions the sole exception to that is that I hope one day to take part in the 24 Hour of Lemons at Sonoma Raceway)

Recently amongst the lemons there has been some discussion about using iRacing Safety Car Generator - an open source race control bot that generates custom safety car or “yellow flag” events - to add some variety to the weekly races. However the instructions for running the app required having a working Python development environment and were beyond the comfort level of the league admins.

The iRacing Safety Car Generator application
The iRacing Safety Car Generator application

PyInstaller#

I was previously aware of Python-based applications that are distributed as bundled executables but I knew nothing about building them or how they worked, so this seemed like a great opportunity to learn. The most common tool used for building these executables today is PyInstaller. From their website:

PyInstaller reads a Python script written by you. It analyzes your code to discover every other module and library your script needs in order to execute. Then it collects copies of all those files – including the active Python interpreter! – and puts them with your script in a single folder, or optionally in a single executable file.

The PyInstaller documentation is worth a read, in particular the “What PyInstaller Does and How It Does It” overview that describes the mechanism behind PyInstaller’s import discovery and how its zip and single-file executable output formats work.

Building using PyInstaller#

PyInstaller is designed to be invoked as a CLI program and accepts as input the entrypoint to your Python application. From there it identifies all of the dependencies that your application relies on and bundles them alongside a Python interpeter into a single executable output.

In practice that looks something like:

python -m PyInstaller \
  --noconfirm \           # Automatically overwrite output without confirmation
  --log-level=WARN \      # Only show warning and error messages during build
  --noconsole \           # Don't open a console window when the app runs
  --onefile \             # Create a single executable file instead of a directory to zip
  --hidden-import=comtypes.gen.UIAutomationClient \  # Specify "hidden import" dependency to include
  --hidden-import=pywinauto.application \            # Specify "hidden import" dependency to include
  --name=iRSCG \          # Set the name of the output executable
  --distpath=./dist \     # Specify where to put the final executable file
  src/main.py

NB: One thing to note is that PyInstaller’s dependency discovery is not 100% complete, and sometimes applications that appear to build will fail at runtime due to what are known as “hidden imports”. To address this PyInstaller allows you to explicitly list these as dependencies as is done here for the comtpypes and pywinauto dependencies.

Packaging for Github Actions#

PyInstaller supports creating executables for Mac OS, Linux, and Windows but it does not support cross-compilation. This means that you must must run the build on an instance of the operating system you’re targeting. I have a Windows installation for iRacing and other games but I wanted to keep that separate from any development work. Instead I packaged the build script to run as a Github Actions workflow to take advantage of their hosted Windows runners.

The following Github Actions workflow allows you to build a branch of the repository on demand and optionally publish the result as a Github release.

name: Build Windows Executable

on:
  workflow_dispatch:
    inputs:
      branch:
        description: "Branch to build"
        required: true
        default: "main"
      version:
        description: "Version number (leave empty for auto-versioning)"
        required: false
      create_release:
        description: "Create GitHub Release"
        required: true
        type: boolean
        default: false
      release_name:
        description: "Release name (required if creating a release)"
        required: false
      prerelease:
        description: "Mark as pre-release"
        required: false
        type: boolean
        default: false

jobs:
  build:
    runs-on: windows-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v3
        with:
          ref: ${{ github.event.inputs.branch }}

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.10"
          cache: "pip"

      - name: Determine version
        id: get_version
        run: |
          if [[ -n "${{ github.event.inputs.version }}" ]]; then
            echo "VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
            echo "USER_VERSION=${{ github.event.inputs.version }}" >> $GITHUB_ENV
          else
            VERSION=$(date +"%Y.%m.%d")
            echo "VERSION=${VERSION}" >> $GITHUB_ENV
            echo "AUTO_VERSION=${VERSION}" >> $GITHUB_ENV
          fi
          echo "BUILD_DATE=$(date +'%Y-%m-%d')" >> $GITHUB_ENV
        shell: bash

      - name: Build executable
        run: |
          python build.py --version "${{ env.VERSION }}" --zip

      - name: List build artifacts
        run: |
          ls -la dist

      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: iRacingSafetyCarGenerator-${{ env.VERSION }}
          path: dist/**

      - name: Create Release
        id: create_release
        if: ${{ github.event.inputs.create_release == 'true' }}
        uses: softprops/action-gh-release@v1
        with:
          name: ${{ github.event.inputs.release_name || format('iRacingSafetyCarGenerator v{0}', env.VERSION) }}
          tag_name: v${{ env.VERSION }}
          files: |
            iRacingSafetyCarGenerator-${{ env.VERSION }}.zip
          draft: false
          prerelease: ${{ github.event.inputs.prerelease }}
          generate_release_notes: true
          body: |
            ## iRacing Safety Car Generator v${{ env.VERSION }}

            This is an automated build from the ${{ github.event.inputs.branch }} branch.

            ### Release Details
            - Branch: ${{ github.event.inputs.branch }}
            - Version: ${{ env.USER_VERSION || env.AUTO_VERSION }}
            - Build Date: ${{ env.BUILD_DATE }}

            ### Installation
            1. Download the zip file
            2. Extract to a folder of your choice
            3. Run the `iRSCG.exe` executable
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

In my tests the build executes consistently in 2 minutes or less, which given Github’s 2,000 minute/month free tier limit for public repositories means that we have quite a number of monthly releases before we have to dig into paid plans.

A successful build on Github Actions in 1m28s
A successful build on Github Actions in 1m28s