Build, test, and deploy PHP applications with GitHub Actions

Build, test, and deploy PHP applications with GitHub Actions

I learned about GitHub Actions when it was released last year, although didn't investigate further as I'd already configured my hobby project CI/CD pipelines using Travis CI to a satisfactory standard.

While everything generally worked really well, I found myself running up against some obscure issue with Travis that meant despite tests completing successfully, the pull request in GitHub would be left with an amber status and a warning about merging untested PRs.

Fast forward to about a month ago, and I thought I'd give it another try as I was running through enhancements to some of the open-source projects I maintain. The suite of tools I built was in response to Acquia's own tooling at the time being unmaintained and using old (and insecure) versions of libraries to power them.

It was also an opportunity for me to practice writing object oriented PHP, Symfony console commands, and test driven development in the real world – especially as my career had moved away from hands-on development a few years ago. Even though I work in less technical roles, I still think it's important to keep my eye in, and as I'll explain in a forthcoming blog about how I got into technology, I tend to learn best when I'm supposed to be working on something else.

For my tools, I decided to abstract the underlying API SDK, rather than package everything into a single library/client tool. I did this so others could consume it and build tools more befitting their needs. I also abstracted log streaming functionality as a one off tool since it solved a very specific purpose. The end-user CLI tool pulls in both as dependencies so we end up with a basic dependency graph as documented below.

Basic dependency graph for PHP packages.

Because each of these exists as its own package, I needed three sets of CI/CD scripts so they could be enhanced and tested in parallel. In keeping with providing the best possible experience for users of these tools, I learned how to create packaged phar applications of the CLI tool and the logstream tool.

Phar applications allow all functionality and upstream dependencies that would typically span hundreds or thousands of PHP files to be pulled together in a single binary. They also omit development/test dependencies and do not require technical knowledge to download and use.

My task was therefore to convert three .travis.yml files into whatever GitHub Actions requires to maintain the same functionality.

Getting started

I figured that I should start off with the simplest package I had, the PHP SDK. In spite of the fact that the underlying functionality and how its tested took (me) a long time to construct, the end result was an SDK that was not only fully tested, but simple to build and use. It doesn't compile into a phar by itself so CI/CD would be a carbon copy of how it's tested locally.

To make things simple, I use composer to lock developer dependencies and composer scripts to manage testing. This means that what I use locally will be exactly the same as what other people download and what will get tested.

A call to composer test runs:

  • PHP linting to make sure we don't have syntax errors
  • Unit testing with phpunit to ensure that expected inputs and outputs are nominal
  • Code sniffs to keep the codebase at a defined quality and standard (PSR-12)
  • Static analysis to detect errors in type and usage etc

The .travis.yml manifest pulls in dependencies, runs the above tests, triggers coveralls to check code coverage, and then, for tagged releases of the CLI and log stream tool, uploads the compiled phar over to GitHub to be linked to the release.

I started off with the default php.yml file provided by GitHub and placed it in the .github/workflows directory. From there, I started to customise each of the steps to align to how my .travis.yml files were set up.

This meant changing the composer install line to being composer install --prefer-source --no-progress --no-suggest --no-interaction and creating a build matrix to test installation on both different operating systems and with the two supported versions of PHP: 7.3, and 7.4.

    runs-on: ${{ matrix.operating-system }}
        operating-system: [ubuntu-latest, macos-latest, windows-latest]
        php-versions: ['7.3', '7.4']
Standard matrix for installing on Ubuntu, Mac, and Windows with PHP 7.3 and 7.4.

I found during the port that there already existed a number of really useful GitHub Actions that I could pull in to run the additional tasks that I needed – predominantly creation of releases and upload of artefacts.

I came to the conclusion during experimentation that I needed to split my manifests into two separate workflows:

  • Build/test
  • Deploy

The reason for this was that a workflow, as well as the jobs and steps inside it, gets triggered based on the on key at the top of the workflow file.

I wanted to run build/test on every single pull request and push to the master branch so it would be triggered when enhancements are being requested and eventually merged in.  

    branches: [ master ]
    branches: [ master ]
Matching all pushes and pull requests to master.

For deployments, I wanted to only create a release and upload a phar of the CLI and log stream tools when I had tagged a release. As this wouldn't occur on a pull request, I've limited it to pushes only. I also restricted the workflow to run when the pattern of the tag committed matched a semantic version. There's a handy cheat sheet on pattern matching within the yaml files that I used to match the tags.

      - '[0-9]+.[0-9]+.[0-9]+'
Running a workflow based on semantic version tags.

I initially tried to bundle the branches and tags parameters in the same workflow file, but found that since GitHub was doing an OR match, I ended up creating releases and deploying on every pull request and push. The result of this mistake can be observed in my releases which I've kept for posterity.

By splitting into two workflows, I could also test against the full matrix of OS and PHP version, but deploy quickly and simply with one version.

One enhancement over Travis that I did find was to make the created phar file and HTML-format code coverage report available for each individual pull request. This meant that for each change requested, the application could be downloaded and tested and a full code coverage report could be reviewed to ensure that new classes and methods were tested. This was all made possible with the upload-artefact action, which took a lot of the pain away from uploading files generated to in the workflow to the output.

All the artefacts uploaded at the end of a successful test run.

The final key to integration made use of the create-release and upload-release-artefact actions. The following code block shows how simple it was to both create the release and then upload the artefact to it. As this workflow only runs on Linux/PHP 7.3, I don't have to contend with the build matrix challenges above, only creating the release and uploading the artefact once. The deploy workflow only runs on a tagged commit, so runs much more infrequently compared to the build and test workflow.

    - name: Create Release
      id: create_release
      uses: actions/[email protected]
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        tag_name: ${{ github.ref }}
        release_name: ${{ github.ref }}
        draft: false
        prerelease: false

    - name: Upload Release Asset
      id: upload-release-asset
      uses: actions/[email protected]
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        upload_url: ${{ steps.create_release.outputs.upload_url }}
        asset_path: ./acquiacli.phar
        asset_name: acquiacli.phar
        asset_content_type: application/octet-stream
Creating a GitHub release and attaching an artefact to it.

The end result can be seen as a result of creating a new release tag and pushing to GitHub. I currently manually enter all of the changes using a custom git command but perhaps in future I'll find a way to make that automated too.


The first challenge I faced was in keeping code coverage functionality. I had previously used Coveralls by creating a clover.xml coverage file with Xdebug. Unfortunately, even though a Coveralls action exists, it expected code coverage in LCOV format. I spent a while tinkering with this, but ended up putting in the too hard box since I couldn't find the right combination of PHP, phpunit, and LCOV to make it all work.

I also clobbered release artefacts for a while because when using the upload-artefact action, any files with the same name overwrite previous uploads of the same name. Since my build matrix tested 8 iterations, I was overwriting 7 times with no certainty about what would be left over afterwards.

Windows support was another issue that plagued me for a while, as I don't have a Windows machine to test on locally. This meant that I'd fire off a change to the manifest and see what was reported back before making another small change and going from there. The main issues for me were that Windows didn't know how to handle /usr/bin/env php in the shebang, PowerShell doesn't use the same commands (or syntax) as Unix, and of course the ubiquitous issue of line endings.

Every single one of my test cases errored out with the following. This is the result of Windows line endings being CRLF instead of LF used on Linux and Mac. As PSR-12 requires LF, errors were raised for each of the test files.

FILE: D:\a\acquia_cli\acquia_cli\src\Cli\AcquiaCli.php
 1 | ERROR | [x] End of line character is invalid; expected "\n" but
   |       |     found "\r\n"
Phpcs error seen on Windows due to line endings.

Additionally, Windows PowerShell doesn't recognise common Unix commands such as find, so my code linting is currently getting ignored on the Windows runners.


To allow the continued check of code coverage, I switched over to the pcov library which can be used in place of Xdebug. This was mainly automatic with the following two entries in the workflow file.

    - name: Setup PHP with pecl extension
      uses: shivammathur/[email protected]
        php-version: ${{ matrix.php-versions }}
        tools: pecl
        extensions: pcov
    - name: Setup pcov
      run: |
        composer require pcov/clobber
        vendor/bin/pcov clobber
Additions to check code coverage with pcov

The pcov library has a requirement on the php-pcov extension which can be installed with pecl. As this is a bit arcane for everyday users, I haven't included it in composer.json as a dependency for any of the packages. Instead, I include a step to install the extension and then require pcov/clobber prior to testing.

Without this, phpunit tests will still run as normal, however code coverage won't be reported to the user which I think is a good middle ground between checking code coverage and making the packages simple to extend without altering PHP installs.

I also altered my phpunit.xml to output in a few different formats as shown below. The formats allow code coverage to be reported to inline in the test log after phpunit finishes running, but also as a downloadable html file. I've documented below how to upload the code coverage file for each run as an artefact.

    <log type="coverage-text" target="php://stdout" showUncoveredFiles="true"/>
    <log type="coverage-clover" target="tests/logs/clover.xml" showUncoveredFiles="true"/>
    <log type="coverage-html" target="tests/logs/phpunit.html" lowUpperBound="35" highLowerBound="70"/>
Logging section of phpunit.xml

I realised that I could prevent test runs from overwriting output artefacts by altering the name based on the build matrix to upload multiple versions. Within my test manifest I have specified the name using matrix variables so they get uploaded as different artefacts.

I've done this for both the compiled phar file as well as the code coverage output which results in a downloadable zip containing a simple, easily navigable HTML site to show tested classes and methods and where more attention to unit testing may be required.

    - name: Upload artefact
      uses: actions/[email protected]
        name: ${{ runner.os }}-php-${{ matrix.php-versions }}-acquiacli.phar
        path: acquiacli.phar
        if-no-files-found: error

    - name: Upload code coverage
      uses: actions/[email protected]
        name: ${{ runner.os }}-php-${{ matrix.php-versions }}-phpunit.html
        path: ./tests/logs/phpunit.html
Upload artefacts and code coverage to the test.

As for Windows, I put this off for a while after trying a few different solutions but without much success. The first issue I faced was in line endings. Initially, I tried to write a PowerShell command to convert each file to Unix file endings as I read on a forum somewhere that unix2dos.exe was included in Cygwin. I also had to add the & as apparently Windows is finicky about a quotation mark appearing first within the braces.

- name: Convert Windows CRLF to LF.
      if: runner.os == 'Windows'
      run: |
        dir ".\src" -recurse -include *.php | %{ & "C:\Program Files\unix2dos.exe" $_.FullName}
        dir ".\tests" -recurse -include *.php | %{ & "C:\Program Files\unix2dos.exe" $_.FullName}
Attempt (failed) at converting line endings using unix2dos.

This didn't work as unix2dos was not installed on GitHub Actions – at least in that location. I did initially research how to add the binary but recognised how doing that would have been tantamount to going down the Rabbit Hole.

Next, I tried a couple of quick perl one-liners to remove the Windows carriage return (\r) from each of the files.

    - name: Convert Windows CRLF to LF.
      if: runner.os == 'Windows'
      run: |
        dir ".\src" -recurse -include *.php | %{ & perl -i -p -e "s/\r//" $_.FullName}
        dir ".\tests" -recurse -include *.php | %{ & perl -i -p -e "s/\r//" $_.FullName}
Attempt (failed) at converting line endings using Perl.

This attempt didn't error out like the unix2dos attempt did, but I was still receiving the same phpcs errors as before so I decided to look for an alternate solution.

My answer came in the form of a GitHub issue raised against the checkout action itself. I opted to create a .gitattributes file within the repository with a single line to match all cloned files and force them to use LF line endings.

* text=auto eol=lf
.gitattributes for consistent line endings across different platforms.

This may have downstream implications for Windows users wishing to enhance the code, however I'm expecting that most Windows users will download the compiled artefact where this won't be an issue. If it does arise again, I'll switch over to the alternate method which will only impact line endings on CI.

      - name: Set git to use LF
        run: |
          git config --global core.autocrlf false
          git config --global core.eol lf
Alternate method for changing line endings on CI.

To solve the Shebang issue that was impacting the build of the phar, I changed the invocation in composer.json to php tools/box compile. When I ran the script directly with ./tools/box compose, the Shebang couldn't find /usr/bin/env on Windows, however by calling php directly, Windows was able to ignore the Shebang.


Overall I'm super happy with the end result and have moved all of the tools over to GitHub Actions to benefit from the tighter integration, the community supported actions, and the ability to easily manage releases and artefacts as part of CI/CD. Hopefully the challenges and solutions I've documented above can aid others who are looking to convert similar packages.

Show Comments