Run multi step job inside Docker Container with GitHub Actions

Run multi step job inside Docker Container with GitHub Actions

How to run a multistep GitHub Action inside a Docker Container for 100% reproducability

Roman Zipp, February 28th, 2024

What

This post will explore the possibility to run multiple GitHub Actions steps inside a Docker container that is built in the same workflow.

I will visialize this with a simple example of a PHP project utilizing PHPUnit, PHPStan and PHP-CS-Fixer but this works in every langauge.

Why

With GitHub Actions you have the possibility to choose from endless available workflows built by the community such as PHP-CS-Fixer or PHPUnit.

The problem with this is - if you're distributing your project as a Docker container (as you should) - that your CI environment can be vastly different from your container environment.

You have the option to pin a specific PHP version but nontheless if you've worked with Docker containers before you will know there are a lot of quirks - such as Alpine images, specific environment variables, multistep build stages, ...

The idea

TLDR; If you just want to see the full workflow file, scroll down.

We want to firstly build a reusable Docker container and then run any tasks in parallel afterwards.

1. Build Container

In this build step we will build the container and save it as a tarball to a temporary directory /tmp/project.tar. To make it later available to other jobs we will upload it as an artifact named project.

build-image:
  runs-on: ubuntu-latest
  steps:
    - name: Checkout repository
      uses: actions/checkout@v4

    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    - name: Build Container
      uses: docker/build-push-action@v5
      with:
        tags: project-image
        context: .
        push: false
        outputs: type=docker,dest=/tmp/project.tar
        build-args: |
          KEY=value

    - name: Upload artifact
      uses: actions/upload-artifact@v3
      with:
        name: project
        path: /tmp/project.tar

2. use Container in parallel jobs

In any other following job, we attach a needs: [ build-image ] to tell GitHub Actions that we depend on the previous job.

The first step is to download previously uploaded artifact (container) project and load it into the Docker daemon.

phpstan:
  name: PHPStan
  runs-on: ubuntu-latest
  needs: [ build-image ]
  steps:
    - name: Download artifact
      uses: actions/download-artifact@v3
      with:
        name: project
        path: /tmp

    - name: Load image
      run: docker load --input /tmp/project.tar

    - name: Run PHPStan
      uses: addnab/docker-run-action@v3
      with:
        image: project-image
        run: vendor/bin/phpstan analyse

3. Cleanup

Since we made use of artifacts, these files will be persisted after the default cleanup time of 30 days. This will take up storage quota from yout GitHub account.

Note to add if: always() to always execute this step - even if previous jobs failed. Also specify all previous build steps in the needs: [] array.

remove-image:
  name: Remove image
  if: always()
  runs-on: ubuntu-latest
  needs: [ build-image, phpstan ]
  steps:
    - uses: geekyeggo/delete-artifact@v2
      with:
        name: project

Note on dependencies

If you're using the same Dockerfile for your production and CI environment, be sure to add a configuratino/environment variable which indicates the Docker build to only install production dependencies outside of the CI.

FROM composer:latest AS build-composer

ARG PRODUCTION=true

WORKDIR /app

COPY . /app

RUN if [[ "$PRODUCTION" = "true" ]]; then composer install --prefer-dist --no-dev --no-scripts --ignore-platform-reqs; else composer install --prefer-dist --no-scripts --ignore-platform-reqs; fi
RUN composer dump-autoload --optimize
build-image:
  steps:
    - name: Build Container
      uses: docker/build-push-action@v5
      with:
        tags: project-image
        push: false
        outputs: type=docker,dest=/tmp/project.tar
        build-args: |
          PRODUCTION=false

Workflow File

Full GitHub Actions workflow file using PHPUnit (+MySQL), PHPStan and PHP-CS-Fixer.

name: Tests

on: [ push ]

jobs:
  build-image:
    name: Build image
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Build Container
        uses: docker/build-push-action@v5
        with:
          tags: project-image
          context: .
          push: false
          outputs: type=docker,dest=/tmp/project.tar
          build-args: |
            KEY=value

      - name: Upload artifact
        uses: actions/upload-artifact@v3
        with:
          name: project
          path: /tmp/project.tar

  phpunit:
    name: PHPUnit
    runs-on: ubuntu-latest
    needs: [ build-image ]
    services:
      database:
        image: mariadb:10.6
        env:
          MYSQL_DATABASE: web
          MYSQL_USER: web
          MYSQL_PASSWORD: web
          MYSQL_ROOT_PASSWORD: root
        ports:
          - 3306
        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v3
        with:
          name: project
          path: /tmp

      - name: Load image
        run: docker load --input /tmp/project.tar

      - name: Run Tests
        uses: addnab/docker-run-action@v3
        with:
          image: project-image
          options: |
            --add-host=host.docker.internal:host-gateway
            -e APP_KEY=base64:abcdefghijklmnopqrstuvwxyz1234567890
            -e DB_PORT=${{ job.services.database.ports[3306] }}
            -e DB_HOST=host.docker.internal
            -e DB_USERNAME=web
            -e DB_PASSWORD=web
            -e DB_DATABASE=web
          run: |
            php artisan passport:keys
            vendor/bin/phpunit --testdox

  phpstan:
    name: PHPStan
    runs-on: ubuntu-latest
    needs: [ build-image ]
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v3
        with:
          name: project
          path: /tmp

      - name: Load image
        run: docker load --input /tmp/project.tar

      - name: Run PHPStan
        uses: addnab/docker-run-action@v3
        with:
          image: project-image
          run: vendor/bin/phpstan analyse

  php-cs-fixer:
    name: PHP-CS-Fixer
    runs-on: ubuntu-latest
    needs: [ build-image ]
    steps:
      - name: Download artifact
        uses: actions/download-artifact@v3
        with:
          name: project
          path: /tmp

      - name: Load image
        run: docker load --input /tmp/project.tar

      - name: Run PHP-CS-Fixer
        uses: addnab/docker-run-action@v3
        with:
          image: project-image
          run: vendor/bin/php-cs-fixer fix --stop-on-violation --dry-run

  remove-image:
    name: Remove image
    if: always()
    runs-on: ubuntu-latest
    needs: [ build-image, phpunit, phpstan, php-cs-fixer ]
    steps:
      - uses: geekyeggo/delete-artifact@v2
        with:
          name: project