It's no secret that over at Laravel we host our code on GitHub. However, because the code to some of our platforms (such as Forge) is proprietary, we try to be careful regarding which services or integrations have access to that code.
As a result, we haven't had any services auto-review our PR's for inconsistencies or simple bugs, and primarily had to rely on offline tools such as PHP-CS-Fixer and ESLint to get the job done.
Now, while most of these tools are pretty great, they unfortunately don't run automatically when you create a PR. Of course, you could configure git hooks with tools such as Husky, but in my experience those often tend to get in the way, both because most people configure their machines slightly differently, as well as that they don't give other people on the team any insights into whether all checks are passing, or whether git hooks were skipped altogether.
So, naturally, when I joined Laravel, one of the first things I did was find a solution to this problem. Ideally, it would do all of the following, without user interaction, and without us launching a new first-party service:
And about ~30 WIP
commits later, here's what we ended up with:
So, let's go over how we've accomplished this, and how you can create any number of these yourself, without the need for too much effort. First of all, here's the script for a PHP-CS-Fixer in all it's glory:
1name: php-cs-fixer 2 3on: [push, pull_request] 4 5env: 6 PR_NUMBER: "${{ github.event.number }}" 7 SOURCE_BRANCH: "$GITHUB_HEAD_REF" 8 FIXER_BRANCH: "auto-fixed/$GITHUB_HEAD_REF" 9 TITLE: "Apply fixes from PHP-CS-Fixer"10 DESCRIPTION: "This merge request applies PHP code style fixes from an analysis carried out through GitHub Actions."11 12jobs:13 php-cs-fixer:14 if: github.event_name == 'pull_request' && ! startsWith(github.ref, 'refs/heads/auto-fixed/')15 runs-on: ubuntu-20.0416 17 name: Run PHP CS Fixer18 19 steps:20 - name: Checkout Code21 uses: actions/checkout@v222 23 - name: Setup PHP25 with:26 php-version: 7.427 extensions: json, dom, curl, libxml, mbstring28 coverage: none29 30 - name: Install PHP-CS-Fixer31 run: |32 curl -L https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/download/v2.18.6/php-cs-fixer.phar -o .github/build/php-cs-fixer33 chmod a+x .github/build/php-cs-fixer34 35 - name: Prepare Git User36 run: |37 git config --global user.name "github-actions[bot]"38 git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"39 git checkout -B "${{ env.FIXER_BRANCH }}"40 41 - name: Apply auto-fixers42 run: php .github/build/php-cs-fixer fix43 44 - name: Create Fixer PR45 run: |46 if [[ -z $(git status --porcelain) ]]; then47 echo "Nothing to fix.. Exiting."48 exit 049 fi50 OPEN_PRS=`curl --silent -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls?state=open"`51 OPEN_FIXER_PRS=`echo ${OPEN_PRS} | grep -o "\"ref\": \"${{ env.FIXER_BRANCH }}\"" | wc -l`52 git commit -am "${{ env.TITLE }}"53 git push origin "${{ env.FIXER_BRANCH }}" --force54 if [ ${OPEN_FIXER_PRS} -eq "0" ]; then55 curl -X POST \56 -H "Accept: application/vnd.github.v3+json" \57 -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \58 "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls" \59 -d "{ \"head\":\"${{ env.FIXER_BRANCH }}\", \"base\":\"${{ env.SOURCE_BRANCH }}\", \"title\":\"${{ env.TITLE }}\", \"body\":\"${{ env.DESCRIPTION }}\n\nTriggered by #${{ env.PR_NUMBER }}\" }"60 fi61 exit 1
Now, let's break it down. Starting off, one thing I'll mention is that I'll be be jumping around the script while explaining instead of just starting at the very top, so you can actually get to understand why things are defined/used the way they are. I promise that by the end of it, we'll have covered all of it.
So, first of all, I'm assuming you've already worked with GitHub Actions, and therefore will probably recognize some of the sections. If not, here's the "generic" bits that you'll need to understand:
1name: name-of-the-workflow 2 3on: [list,of,events,which,should,trigger,this,workflow] 4 5env: 6 environmentKey: value 7 8jobs: 9 job-key-unique-to-this-workflow:10 if: some-condition-that-determines-whether-this-job-should-run-or-be-skipped11 runs-on: operating-system-that-the-job-should-run-on12 13 name: Pretty display name of the job14 15 steps:16 - name: Name of the first step17 otherThings: Some supported thing to actually execute
For a more in depth explanation, I'd recommend you to check out the syntax here.
So, one thing that we'll see straight away, is that we have a somewhat complex if
-statement here:
1jobs:2 php-cs-fixer:3 if: github.event_name == 'pull_request' && ! startsWith(github.ref, 'refs/heads/auto-fixed/')4 runs-on: ubuntu-20.045 6 name: Run PHP CS Fixer
github.event_name == 'pull_request'
As you can see, we're explicitly making sure that the event_name
is a pull_request
, and the reason that we do this
instead of just removing the on: ['push']
from the top of the workflow, is to make it re-runs every time we push a new commit to the branch for which a pull request exists.
One downside of this is that you'll always get two 'actions' that are running, one of which is always skipped, but as far as I'm aware, this is unfortunately the best we can do for now.
! startsWith(github.ref, 'refs/heads/auto-fixed/')
Then, there's the second part. This basically ensures that when a Fixer PR is created, the action doesn't run for those by checking that the branch itself starts with auto-fixed/
.
There's a few reasons we do this:
I don't think this needs any explanation, but our first step is to basically checkout / pull the code from the commit that triggered it:
1- name: Checkout Code2 uses: actions/checkout@v2
Next, here's one part that you might want to customize depending on the type of fixer you're creating.
1- name: Setup PHP 3 with: 4 php-version: 7.4 5 extensions: json, dom, curl, libxml, mbstring 6 coverage: none 7 8- name: Install PHP-CS-Fixer 9 run: |10 curl -L https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/download/v2.18.6/php-cs-fixer.phar -o .github/build/php-cs-fixer11 chmod a+x .github/build/php-cs-fixer
As you can see, we're first setting up PHP 7.4 with a few extensions, using shivammathur/setup-php. If you haven't used this before, it's surprisingly fast, and in contrast to a normal PHP install, it only takes a few seconds to execute instead of minutes.
Afterwards, we downloading the PHP-CS-Fixer itself to .github/build
, so if you're planning to use this workflow, make sure you create this (non-existent) folder first.
I recommend placing a .gitignore
file inside of it, with the following contents:
1*2!.gitignore
This way, the PHP-CS-Fixer doesn't constantly commit it's downloaded binaries to your repository through it's Fixer PR's. Of course, do keep in mind that you might not need to do this for other types of fixers, but for this specific example, it's fairly important.
Next, we'll configure git
and check out our Fixer branch.
You might wonder where we got the name
and email
from, and the answer to that's pretty simple: It's what GitHub uses!
While the name was pretty easy to discover, we only found it's email by going through GitHub's events API for that user.
My colleague @jbrooksuk did note that the bot might've switched to use
[email protected]
, but by the time he mentioned this I had already configured this one email, so 🤷♂️
1- name: Prepare Git User2 run: |3 git config --global user.name "github-actions[bot]"4 git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"5 git checkout -B "${{ env.FIXER_BRANCH }}"
Finally, we check out the Fixer branch, using GitHub's -B
flag. If you're not familiar with this flag, it either creates or resets the branch if it already exists.
env
variablesAs you might've noticed, this is also the first time we were referencing the env
variables, which are configured near the top of the script.
You can only define these once per GitHub Actions workflow file, so let's go over those real quick:
1env:2 PR_NUMBER: "${{ github.event.number }}"3 SOURCE_BRANCH: "$GITHUB_HEAD_REF"4 FIXER_BRANCH: "auto-fixed/$GITHUB_HEAD_REF"5 TITLE: "Apply fixes from PHP-CS-Fixer"6 DESCRIPTION: "This merge request applies PHP code style fixes from an analysis carried out through GitHub Actions."
I believe what we're doing here speaks for itself as well, but one thing I would like to mention is that it apparently isn't possible
to reference environment variables that are already defined. This is why we ended up having to use the $GITHUB_HEAD_REF
variable twice.
Finally, this is the step where our fixers are run. So, let's run our PHP-CS-Fixer:
1- name: Apply auto-fixers2 run: php .github/build/php-cs-fixer fix
Let's split this script up even further, and step through it section by section, as this is the meat and potatoes of the whole workflow.
1- name: Create Fixer PR 2 run: | 3 if [[ -z $(git status --porcelain) ]]; then 4 echo "Nothing to fix.. Exiting." 5 exit 0 6 fi 7 OPEN_PRS=`curl --silent -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls?state=open"` 8 OPEN_FIXER_PRS=`echo ${OPEN_PRS} | grep -o "\"ref\": \"${{ env.FIXER_BRANCH }}\"" | wc -l` 9 git commit -am "${{ env.TITLE }}"10 git push origin "${{ env.FIXER_BRANCH }}" --force11 if [ ${OPEN_FIXER_PRS} -eq "0" ]; then12 curl -X POST \13 -H "Accept: application/vnd.github.v3+json" \14 -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \15 "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls" \16 -d "{ \"head\":\"${{ env.FIXER_BRANCH }}\", \"base\":\"${{ env.SOURCE_BRANCH }}\", \"title\":\"${{ env.TITLE }}\", \"body\":\"${{ env.DESCRIPTION }}\n\nTriggered by #${{ env.PR_NUMBER }}\" }"17 fi18 exit 1
First, we'll call git status --porcelain
,
which will just give us a list of files that have changed. If there are no changes, it'll output an empty string. We can then test this using bash
's empty string test (-z
):
1if [[ -z $(git status --porcelain) ]]; then2 echo "Nothing to fix.. Exiting."3 exit 04fi
If we don't have any changes, we'll print that to the console (easier debugging), and exit with a 0
status, meaning that the process finished successfully / without errors.
We have nothing to do, hurray! Good job developer!
GitHub Actions will interpret this exit code as a passing check on the PR. Similarly, exiting with a 1
status would cause the GitHub Actions check to fail.
However, if we do have changes (meaning we need a Fix PR), we'll fetch a list of open PR's using the GitHub API, and store the output in a bash variable. We do this to ensure we only create a PR if one does not already exist:
1OPEN_PRS=`curl --silent -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls?state=open"`
The --silent
flag here makes sure that curl doesn't output any connection details, and the rest is basically the crafting of a a JSON request.
What's interesting/relevant here, is that we're using a few magic variables:
${{ secrets.GITHUB_TOKEN }}
: This is an automatically created GitHub secret that can be used to authenticate with GitHub.
This is what allows us to both call the API to fetch the open PR's, even on private repositories.
$GITHUB_REPOSITORY
: This is a default environment variable that's also set by GitHub Actions.
This allows us to not have to hard-code the current repository in the script. Nice!
Next, we'll check whether our OPEN_PRS
variable contains a PR that originates from our Fixer branch.
To do this, we'll first use grep
with the -o
flag, which will print every matching result on a separate line.
1OPEN_FIXER_PRS=`echo ${OPEN_PRS} | grep -o "\"ref\": \"${{ env.FIXER_BRANCH }}\"" | wc -l`
We then pipe this output to wc -l
, which counts the number of lines, and store that number in an OPEN_FIXER_PRS
variable for future use.
With that done, we'll create a commit with our changes, and force push them to our fixer branch.
1git commit -am "${{ env.TITLE }}"2git push origin "${{ env.FIXER_BRANCH }}" --force
The reason we're using a force push here, is because we really only care about the latest changes/fixes, as well as that it prevents accidental merge conflicts.
Finally, we'll check whether all we needed to do was push, or whether we need to create a Fix PR as well.
This is done using by checking the number of OPEN_FIXER_PRS
:
1if [ ${OPEN_FIXER_PRS} -eq "0" ]; then2 curl -X POST \3 -H "Accept: application/vnd.github.v3+json" \4 -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \5 "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls" \6 -d "{ \"head\":\"${{ env.FIXER_BRANCH }}\", \"base\":\"${{ env.SOURCE_BRANCH }}\", \"title\":\"${{ env.TITLE }}\", \"body\":\"${{ env.DESCRIPTION }}\n\nTriggered by #${{ env.PR_NUMBER }}\" }"7fi
If there's no existing PR's, we'll create one.
You'll notice that we're creating a JSON payload here, with the Fixer PR's as the source (head
), the current PR's branch as the target (base
),
the title that we've configured in the env
details at the top of the script (to keep things simpler down here), as well as our description (body
).
As a cherry on the top, we append a few blank lines to the body
field (our description), and reference the original PR's number.
This will trigger GitHub to create a reference to the Fix PR in the details / on the timeline of the original PR, making it easier for the PR's creator and reviewers to find.
Finally, we'll always fail our check by exiting with an exit 1
(error) status code, regardless of whether we're creating a new PR or not.
As mentioned before, this causes the GitHub Actions check to fail on the PR.
Our workflow is now complete.
Another workflow we have is almost identical, but instead fixes Javascript and Vue code style inconsistencies. To show how easy it is to create a new fixer, we'll first copy the entire workflow from earlier, and swap out the relevant parts:
1name: php-cs-fixer 2name: js-cs-fixer 3 4on: [push, pull_request] 5 6env: 7 PR_NUMBER: "${{ github.event.number }}" 8 SOURCE_BRANCH: "$GITHUB_HEAD_REF" 9 FIXER_BRANCH: "auto-fixed/$GITHUB_HEAD_REF"10 TITLE: "Apply fixes from PHP-CS-Fixer" 11 DESCRIPTION: "This merge request applies PHP code style fixes from an analysis carried out through GitHub Actions." 12 TITLE: "Apply fixes from JS-CS-Fixer" 13 DESCRIPTION: "This merge request applies JS code style fixes from an analysis carried out through GitHub Actions." 14 15jobs:16 php-cs-fixer: 17 js-cs-fixer: 18 if: github.event_name == 'pull_request' && ! startsWith(github.ref, 'refs/heads/auto-fixed/')19 runs-on: ubuntu-20.0420 21 name: Run PHP CS Fixer 22 name: Run JS CS Fixer 23 24 steps:25 - name: Checkout Code26 uses: actions/checkout@v227 28 - name: Setup PHP 30 with: 31 php-version: 7.4 32 extensions: json, dom, curl, libxml, mbstring 33 coverage: none 34 35 - name: Install PHP-CS-Fixer 36 run: | 37 curl -L https://github.com/FriendsOfPHP/PHP-CS-Fixer/releases/download/v2.18.6/php-cs-fixer.phar -o .github/build/php-cs-fixer # [tl! .diff-remove]38 chmod a+x .github/build/php-cs-fixer # [tl! .diff-remove]39 - name: Set up Node & NPM 40 uses: actions/setup-node@v2 41 with: 42 node-version: '14.x' 43 44 - name: Get yarn cache directory path 45 id: yarn-cache-dir-path 46 run: echo "::set-output name=dir::$(yarn cache dir)" 47 48 - uses: actions/cache@v2 49 id: yarn-cache 50 with: 51 path: ${{ steps.yarn-cache-dir-path.outputs.dir }} 52 key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 53 restore-keys: ${{ runner.os }}-yarn- 54 55 - name: Install yarn project dependencies 56 run: yarn 57 58 - name: Prepare Git User59 run: |60 git config --global user.name "github-actions[bot]"61 git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"62 git checkout -B "${{ env.FIXER_BRANCH }}"63 64 - name: Apply auto-fixers65 run: php .github/build/php-cs-fixer fix 66 run: yarn fix-code-style 67 68 - name: Create Fixer PR69 run: |70 if [[ -z $(git status --porcelain) ]]; then71 echo "Nothing to fix.. Exiting."72 exit 073 fi74 OPEN_PRS=`curl --silent -H "Accept: application/vnd.github.v3+json" -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls?state=open"`75 OPEN_FIXER_PRS=`echo ${OPEN_PRS} | grep -o "\"ref\": \"${{ env.FIXER_BRANCH }}\"" | wc -l`76 git commit -am "${{ env.TITLE }}"77 git push origin "${{ env.FIXER_BRANCH }}" --force78 if [ ${OPEN_FIXER_PRS} -eq "0" ]; then79 curl -X POST \80 -H "Accept: application/vnd.github.v3+json" \81 -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \82 "https://api.github.com/repos/$GITHUB_REPOSITORY/pulls" \83 -d "{ \"head\":\"${{ env.FIXER_BRANCH }}\", \"base\":\"${{ env.SOURCE_BRANCH }}\", \"title\":\"${{ env.TITLE }}\", \"body\":\"${{ env.DESCRIPTION }}\n\nTriggered by #${{ env.PR_NUMBER }}\" }"84 fi85 exit 1
In this case, instead of installing PHP like before, we install Node.js, as well as our project's dependencies (which in our case includes Prettier and ESLint)
Because installing Node dependencies can take a long time, we also prepare a yarn cache up front, that GitHub Actions will automatically re-use on sequential 'builds'.
Finally, you can see we run yarn fix-code-style
, which is a custom command that's defined within our project's package.json
. Here's the relevant bits:
1{ 2 "scripts": { 3 "fix:eslint": "eslint --ext .js,.vue resources/js/ --fix", 4 "fix:prettier": "prettier --write --loglevel warn 'resources/js/**/*.js' 'resources/js/**/*.vue'", 5 "fix-code-style": "npm run fix:prettier && npm run fix:eslint" 6 }, 7 "devDependencies": { 8 "eslint": "^7.25.0", 9 "eslint-plugin-vue": "^7.9.0",10 "prettier": "^2.3.0"11 }12}
Setting up these 'scripts' wasn't really necessary, but it gives us an easy way to run both Prettier and ESLint (in the configured order) locally as well.
Hope this was useful!