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 visualize this with a simple example of a PHP project utilizing PHPUnit, PHPStan and PHP-CS-Fixer but this of course works for every language or tooling.
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 that - if you're distributing your project as a Docker container (as you should) - your CI environment can be vastly different from your container environment.
You have the option to pin a specific PHP version but nonetheless if you've worked with Docker containers before you will know there are a lot of quirks - such as Alpine images, specific environment variables, multi-step build stages, ...
Why in parallel
Speed.
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 your 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
Notes!
Dependencies
If you're using the same Dockerfile for your production and CI environment, be sure to add a configuration/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-cache --no-scripts --ignore-platform-reqs --no-dev; \ else \ composer install --prefer-dist --no-cache --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
Quota Usage
If your workflows are running for a longer time and GitHub Actions quota is important to you, note that we have observed that running multiple steps in parallel using different jobs counts more to your quota since every job counts against your quota on its own. The following example is not representative of actual execution times since the job logs originate from different points in time but rather visualize the issue.
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