Jay Gould

Using GitHub Actions for a modern web development workflow

November 15, 2019

A couple of years ago I wrote a blog post about my workflow with WordPress. It’s been years since then and actually years since I’ve worked with WordPress at all, until recently. I’ve jumped back in to WordPress dev over the last month or so, and decided to write this post to share how GitHub Actions have become an integral part of my WordPress deployment.

This is a two part post, this first of which doesn’t cover WordPress at all, but instead focuses on GitHub Actions, why they are great, and how to use them to create a modern web development workflow in general. The second part will relate GitHub Actions that to a simple but effective automated WordPress workflow.

  • Part 1: About GitHub Actions and how to use for a web development workflow - [you are here]
  • Part 2: Integrating GitHub Actions in to WordPress - [coming soon]

About GitHub Actions

GitHub Actions is a new service offered by GitHub which provides excellent CI/CD (continuos integration/continuos deployment) services to be used alongside your GitHub repo. The service has been in public beta for the last 18 months, but is going to be released to the public shortly on 13th November 2019.

Similar to the likes of CircelCI and GitLab, GitHub Actions provides a way to run a set of actions which are combined together to form a “workflow”.

Terminology is important when it comes to thee tools, so check out the GitHub Actions help page for a full list of the lingo.

The aim of a workflow is to take your repository and automate something using the code in the repo. The possibilities are huge, but to give an idea of what GitHub actions can do, this is a useful link. You can automate things like building, testing and deploying your code, sending Slack notifications or SMS messages, release NPM packages, publish apps to the app store, or even hooking in to your smart home devices.

This automation and endless possibilities is all possible because the actions are essentially self-contained tasks which run on a virtual server. They can be ran on Mac, Linux or Windows servers for example, and run any kind of scripts to send/received data between themselves and anywhere on the web.

As the possibilities really are huge, this post will be focussing on a specific use case which is to build, test and deploy website code to a remote linux server. Specifically, we want to be able to run a GitHub action when our GitHub repo receives a push. The action will build out the site assets, run some tests, and only if the tests pass will the site be deployed to a server.

Example Github Actions workflow

I’ll show a Hello World example and then move on to creating a GitHub Actions workflow specific to a modern web development use case. In order to get started, you should create a file at the following directory:

.github/workflows/main.yml

GitHub actions uses YAML file syntax, but have only been using this recently. It had previously used HCL syntax, but has since adopted the YAML syntax which is common amongst most CI/CD services.

This main.yml file should contain the following to get started:

# The name which appears in your GutHub Actions tab on GitHub
name: Example workflow

# The workflow is triggered when your repository is pushed to
on: [push]

# Each job is defined - this workflow contains the "build" job
jobs:
  build:
    # Job name is Greeting
    name: Greeting
    # The job runs on Linux
    runs-on: ubuntu-latest
    steps:
      # This step uses GitHub's hello-world-javascript-action: https://github.com/actions/hello-world-javascript-action
      - name: Hello world
        uses: actions/hello-world-javascript-action@v1
        with:
          who-to-greet: "Mona the Octocat"
        id: hello
      # This step prints an output (time) from the previous step's action.
      - name: Echo the greeting's time
        run: echo 'The time was ${{ steps.hello.outputs.time }}.'

Once this file is committed to your repo and your repo is pushed, head over to your GitHub account, and click the “Actions” tab on your repo. You should see the “Example workflow” workflow present. This workflow should actually be running because we’ve set it to run when your repo is pushed to with the on: [push] line.

A few important things to mention at this point which I may not directly cover in the post:

  • You can set the workflow to run in other instances besides being pushed to, such as when a PR is created, or a release is created.
  • When a branch is merged in to another, the receiving branch will class this as a push and initiate the workflow.
  • You can set a workflow to run when only a specific branch is pushed to, and/or the commit contains specific tags.
  • The steps, indicated by a - in the above example, each show as an event in the job, which can either pass or fail. For example, the first step above has a name of “Hello World”, and this will appear when the job is running.
  • A step must include either a uses: or run: line. uses: will initiate an action, whilst run: will run a command. In the above example we’re running this workflow on an Ubuntu server, so the run: will be able to do anything such as install packages, manipulate the file system etc.
  • You can access the output from any previous, and perform logic based on the output. This is useful for complex workflows. For example, the line above ${{ steps.hello.outputs.time }}.
  • You can access “secrets” which are set in the GitHub admin area. These secrets can be things like API keys or passwords which are not supposed to be committed to your Git repo.
  • The uses: must refer to an action which is ran using an external file. This allows for many actions to be combined to create a complex workflow.

Docker vs JavaScript actions

It’s the last step above which is worth elaborating on a bit more. The actions are the meat and potato of your workflow - the .yml file just orchestrates the actions. The actions can be ran in either a Docker container, or as pure JavaScript.

Docker actions

The Docker contained actions have the advantage of being a self-contained environment with specific OS and package versions, meaning they are SO much more reliable. I have a project which runs a Webpack build command but does not work on newer versions of Node due to out of date lib-sass or something. When I deploy this to a new environment with the latest Node install it will not work. Docker with GitHub actions ensures I can run a specific Node version in my container which will guarantee to output the same files I built locally.

On the other hand, each Docker action requires significant time to boot up and install dependencies which takes around 20-30 seconds in my experience. With multiple Docker actions (a common scenario), this leads to a lengthy workflow.

Docker actions run a shell script initiated from a Dockerfile.

JavaScript actions

JavaScript actions are ran directly on the virtual machines at GitHub. They require little setup time, making them much faster than Docker containers.

JavaScript actions consist of just JS files and run in a Node environment.

Which action type is the best to use? It comes down to your use case as always. It might be easier for us web developers to script something up in JS compared to a shell script, but shell scripts allow you to access raw linux command line to initiate specific things like FTP’ing files (which I’ll cover later). I recommend trying a few of them out and deciding for yourself. I’ll give examples of both in this post though.

A web developers GitHub Actions workflow

Now I’ve covered the basics above, I’ll go in to a little detail about a workflow which explores GitHub actions deeper, using more features. I think it’s helpful to see how they work in a real-life environment and not just a Hello World example.

My workflow represents a generic modern web development situation. When a commit is pushed/merged in to the “staging” branch, a staging workflow will initiate. There’s also a production workflow which runs when the master branch is committed to. The workflow will build the front and back end of my web site/app but running composer update, npm install, and npm run build. When the files are built, the front and back end test suites are initiated, and depending on the outcome of the tests, the workflow will either stop or deploy all files to the production/staging website (depending on which branch was pushed to).

The file structure

The file structure for this workflow should be simple for demo purposes:

- /.github
--  /workflows
---   main.yml
- /src
- /assets
- package.json
- composer.json
- .gitignore

This post assumes knowledge of how modern web applications are built using NPM and Composer package managers.

The .github directory contains all files for the GitHub actions. The src directory contains the JS, CSS and image files which you’d expect to see in a website. The package.json and composer.json contain the front end and back end build starting point respectively. The .gitignore file will contain the files to keep out of the Git repo, which will be the node_modules and vendor directories.

This file structure will be pushed to GitHub, and accessed by the workflow in order to process the build and deployment.

When pushed, the workflow will

The workflow

# .github/workflows/main.yml

name: Production CI

on:
  push:
    branches:
      - master

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout files and setup environment
        uses: actions/checkout@v1
      - name: Set up Node
        uses: actions/setup-node@v1
        with:
          node-version: "10.15.3"
      - name: Build plugins
        run: composer update
      - name: Build assets
        run: npm install && npm run build
      - name: Upload PHP
        uses: actions/upload-artifact@v1
        with:
          name: php-build
          path: vendor
      - name: Upload assets
        uses: actions/upload-artifact@v1
        with:
          name: js-build
          path: assets
      - if: success()
        name: Send build success message
        uses: jaygould/action-slack-notify@master
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_USERNAME: Git message
          SLACK_MESSAGE: The build process succeeded
          SLACK_ICON: https://jaygould.co.uk/icons/icon-192x192.png
      - if: failure()
        name: Send build fail message
        uses: jaygould/action-slack-notify@master
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_USERNAME: Git message
          SLACK_MESSAGE: The build process failed
          SLACK_ICON: https://jaygould.co.uk/icons/icon-192x192.png
          SLACK_COLOR: "#BE2625"

  deploy:
    needs: [build]
    runs-on: ubuntu-latest
    steps:
      - name: Prepare for deployment
        uses: actions/checkout@master
      - name: Download PHP
        uses: actions/download-artifact@v1
        with:
          name: php-build
          path: vendor
      - name: Download assets
        uses: actions/download-artifact@v1
        with:
          name: js-build
          path: assets
      - name: Deploy website
        uses: jaygould/FTP-Deploy-[email protected]
        env:
          FTP_SERVER: ${{ secrets.FTP_HOST }}
          FTP_USERNAME: ${{ secrets.FTP_USERNAME }}
          FTP_PASSWORD: ${{ secrets.FTP_PASSWORD }}
          REMOTE_DIR: public_html
          ARGS: --only-newer --ignore-time --verbose
      - if: success()
        name: Send deployment success message
        uses: jaygould/action-slack-notify@master
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_USERNAME: Git message
          SLACK_MESSAGE: The deployment process succeeded
          SLACK_ICON: https://jaygould.co.uk/icons/icon-192x192.png
      - if: failure()
        name: Send deployment fail message
        uses: jaygould/action-slack-notify@master
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
          SLACK_USERNAME: Git message
          SLACK_MESSAGE: The deployment process failed
          SLACK_ICON: https://jaygould.co.uk/icons/icon-192x192.png
          SLACK_COLOR: "#BE2625"

Ok there’s a lot going on here so I’ll cover relevant areas, including the steps.

Workflow setup

Firstly the workflow defines that it should be ran when the repo is pushed to the master branch. The first job is defined just below that, which is called build. Half way or so down the code snippet is the second and final job, which is deploy. As I mention above, the workflow will first build the web app, then deploy to either production or staging environment depending on which branch was pushed to.

Checkout files

Going back to the build job, the first step is named “Checkout files and setup environment”. This uses the GitHub action called ”checkout” which checks out the repo to be used later on in the workflow. Without this checkout action, we wouldn’t be able to access any files in the repo, so this is pretty much needed for most situations. Another thing worth noting is that the checkout action is standard action created by GitHub actions. GitHub actions contains a tonne of action which can be used in the same way such as setting up a Node environment, TS environment, or interacting with other third party services like Heroku.

Setup and specify Node environment

Now that the files are checked out, the next step is named “Set up Node”. This uses another of GitHub’s baked in actions to set up a Node environment. In my example I’m using the with option to set my desired Node version which matches my local environment.

Install PHP and JS dependencies, and build front end assets

Next step is to install the PHP dependencies using composer update, and JS dependencies using npm install && npm run build. This is all ran on a Linux environment which comes pre-installed with popular software like Composer and NPM. This will output the vendor and node_modules directories to the VM, and build out what ever assets we have (such as compiling Sass to CSS, compressing images etc.).

Upload built files for later use

Jobs in workflows run in parallel and completely independently. This is where the magic of GitHub actions really shines, because we’re able to upload the assets (such as the vendor files and front end assets) to “artifact storage”. Using the actions/upload-artifact@v1 action from GitHub, we’re able to store them temporarily, and access them in another part of the workflow.

The first line under the deploy: line shows needs: [build]. This shows that the deploy job must wait until the build job is finished, without errors, before proceeding. This is great as it means if there are any errors in the build or testing process, the deployment will stop!

Sending Slack messages

Just at the end of the first job (build) is an area to handle notifications. We want to send a notification which tells us if the build process was a success or not. This uses conditional logic which is based on the current status of the job. In the example above, if: success() will run the step if the job has been successful up until now, else it will not run that step.

With either success or fail outcome, the final part of our build job uses the jaygould/action-slack-notify@master action to send a Slack message which is linked to my Slack account using the action env parameters like I mentioned earlier. I also add my Slack webhook via the Git secrets area which I also mentioned earlier in the post.

The Slack notification action is forked from another repo but I decided to use my version in case I wanted to customise it (and also keeps it consistent in case the original author changes theirs). If you visit the action repo you’ll see that it contains a Dockerfile, meaning this is an example of an action which has been created using Docker and a shell script.

Deploying files

The second and final job is deploy. As already mentioned this will wait until the build job is complete, and is also required to checkout the files again. As the build was definitely successful (else this job will not have ran), we know we can deploy. We must deploy with the built files from the build job, so we can use the actions/download-artifact@v1 action to download the previously uploaded built files. We define the path we want to output the files to, and we’re ready to deploy!

The deployment action is near the bottom, which uses the jaygould/[email protected] repo which I’ve forked from another source. Under the hood this uses the lftp Linux command to transfer the files to a desired location, of which the location is defined with GitHub secrets.

Finally, the workflow will send a second and final Slack notification if the deployment was a success, using the same action as the previous notifications from the build job.

When the above workflow is pushed to your repo, you’ll see something like the following in the “Actions” tab:

GitHub Actions main screen

My screenshot shows a few workflow’s I’ve ran in my demo repo. There’s one currently running called “Staging CI”, but this will be called whatever you name the workflow in your YAML file (in our example here is “Production CI”).

GitHub Actions job screen

When clicking through to each workflow, you can see the jobs within the workflow on the left, and the steps within each job on the right of the screen with the success status next to each one.

Public and private GitHub actions

A few other things/opinions which are worth elaborating on. You can use actions from either a public repository or by embedding them directly in the repo which is calling them. My example here references actions which are hosted on my GitHub account - I feel this keeps everything tidy, and means I can re-use the actions across multiple projects easier. It might be that you want to use an action which you’ve created which you don’t want to be publicly available. In this case you must embed directly in your workflow. To embed the Slack notification workflow for example, just use this in your workflow file:

  ...
  name: Send build success message
  uses: ./.github/actions/action-slack-notify@master
  env:
    SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
    SLACK_USERNAME: Git message
    SLACK_MESSAGE: The build process succeeded
    SLACK_ICON: https://jaygould.co.uk/icons/icon-192x192.png
  ...

This will mean you could have a secondary directory in your repo just for actions which are contained just within the repo. You can then make the repo private and you’re good to go!

FTP deployment action

Another important part of this workflow is the deployment process. There are tools out there such as DeployHQ which I’ve used at work over the last year or so, and while these work great for their purpose, they don’t give you the flexibility which comes with a fully-fledged CI/CD workflow. However one thing which DeployHQ does well is how fast it transfers the files. It does this by only transferring the files which have changed - something which is not covered in the README of the original action.

When using this action, it’s crucial that correct arguments are passed to the lftp process which are:

ARGS: --only-newer --ignore-time --verbose

ignore-time argument ensures that when the code is deployed, the timestamp is not updated. Without this, the whole repo is deployed each time your branch is pushed. Similarly, the only-newer argument tells lftp to only transfer files which have recently been updated. verbose helps to debug and see which files are uploading, as well as giving feedback on progress :)

It will be easy enough to adapt the FTP deploy action to use something like rsync or scp to transfer files if SFTP is not available, and this could make a decent action for people to use.

The end

Thanks for reading, I hope this was a useful introduction to GitHub actions. I’ll be writing the second post shortly covering a WordPress specific environment, so stay tuned if you’re interested in automating WordPress deployment, and keeping a tidy, modern repo to handle WordPress plugins, database migrations, and asset management.


Senior Engineer at Haven

© Jay Gould 2023, Built with love and tequila.