Deploying and Building Laravel with GitHub Actions

by DevsrealmGuy

Explore a real world example of using GitHub Actions for your Laravel applications. Follow along with a step-by-step guide that includes a sample GitHub repository and a complete GitHub Workflow.

Introduction

In this guide, you'll learn how to deploy a Laravel Application with GitHub Actions on your Amezmo Instance using the Amezmo API without the need of installing Composer dependencies and building NPM assets at deployment time. The assets are built on GitHub, and then sent to Amezmo.

Prerequisites

Note: The source code is on GitHub at amezmo/github-actions-demo You may follow along or jump straight to the source code.

GitHub Actions

For those new to GitHub Actions, it is simply a way to trigger action or series of actions from events generated on a GitHub repository.

An example of this is building a workflow (series of actions) that automates the process of testing and building your code on a build server (Free GitHub-hosted virtual machines) before merging it. Doing this can reduce the risk of breaking stuff, which is especially important in a team project.

Think about it for a second, wouldn't it be cool if you can trigger a server somewhere that takes the changes you (or contributors) committed to your repository, run the test or series of actions, and get a report if it fails (and why it fails) or passes before being merged to the repo? It can save you a lot of time!

Typically, you'll need to test the changes thoroughly on your local machine, but when using GitHub Actions, you leverage their CI server that checks for new code commits in a repository all for free.

We can leverage this practice by using GitHub Actions for automatically deploying our code to production environment after ensuring that all of our specified actions have passed, the flow is something like below:

GitHub Action Flow Chart

Forgive my diagram, but I believe you got the point: There are a couple of ways you can customize the workflow, you can either trigger actions from events when a pull request is submitted or when changes occur in a specific branch (you can even do the two). GitHub runs the actions and provides the results of each test in the pull request, this way, you can check why a workflow is failing.

Here is the scope of what we would be doing:

  • Once a pull request is accepted into the master branch...
  • it triggers our workflow which would contain 2 jobs [build and deploy] that would run sequentially, when one fails, it halts without moving to the next one.
  • "build job" would be used to test our code, and we would also automatically bundle it into a zip file and release the artifacts, if that is successful, we deploy the project to production with the "deploy job"

Note: Artifacts are files produced during a workflow run, since we don't want to do composer install or npm install on the production stage, we use the one we've generated in our build, so, the artifact are the files generated when the build runs

Let's get into action:

Step 1: Clone the Sample Project

Note: If you already have an App you want to deploy, proceed to Step 2

I have provided the sample Laravel project on GitHub. The only dependency in this package is laravel/ui package, nothing more]. You may use the repository to follow along in this guide.

Clone it into your dev environment:

git clone https://github.com/amezmo/github-actions-demo

Import it into your GitHub Repo, and proceed to the next step.

Step 2: Set Amezmo API Key and Site ID in GitHub Action Secrets

Grab your API_KEY in your Amezmo app instance by clicking on your Avatar Icon -> Profile -> API Keys:

GIF image showing how to find your Amezmo API key

Copy the key to somewhere safe.

Get your SITE ID by navigating to the Overview tab, scroll all the way to Application details, copy the numeric ID right next to the ID label:

GIF image showing how to find your Amezmo instance ID

Now, go into your GitHub repo project Settings -> Secrets.

Click on "New repository secret" button add API_KEY as the Name, and paste the Amezmo API Key you just copied as the value, for example:

Add Amezmo API KEY To Repo Secret For GitHub Actions

Repeat the steps for SITE ID, add SITE_ID as the Name, and paste the Amezmo SITE you just copied as the value.

Why are we doing this?

Short Answer: To hide sensitive information.

Long Answer: We would be exposing the API Key and SITE ID (the site we want to deploy the app into) in the workflow when deploying the app to production, keeping the key and site_id in the Action Secrets ensures it is encrypted and not plainly exposed to everyone that has access to the repo except those that have collaborator access.

Step 3: Add Environment Configuration in and Set the Public document root in Amezmo

Goto the configuration tab of your site instance in Amezmo, and add the following:

APP_NAME=SITE-NAME
APP_KEY=app_key
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=database_name
DB_USERNAME=database_user
DB_PASSWORD=database_pass

This would overwrites or create any .env file in your application's root directory and is shared across deployments. In other words, instead of having to edit .env file for every deployment, you can just set it once here and it would be shared across deployment.

Feel free to add more environmental variable that pertains to your Laravel application.

Finalize this step by adding /public under Public document root Which is located in the Nginx tab

Step 4: Setup GitHub Action Workflow

Workflows should be placed under .github/worflows directory, if you cloned the sample app repo, I already have a workflow named main.yml which is under .github/workflows, it contains the following:

name: Laravel Build and Deploy To Amezmo
on:
  push:
    branches: [master]
jobs:
  build:
    runs-on: ubuntu-latest # Machine To Run On
    services: # Service container Mysql
      mysql: # Label used to access the service container
        image: mysql:5.7
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_DATABASE:  github_actions_demo
        ports:
          - 33306:3306
        options: >-  # Set health checks to wait until mysql database has started (it takes some seconds to start)
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3
    steps:
      - uses: actions/checkout@v2
      - name: Node.js Setup
        uses: actions/setup-node@v2
        with:
          node-version: 16.x
      - name: Cache node_modules directory
        uses: actions/cache@v2
        id: node_modules-cache
        with:
          path: node_modules
          key: ${{ runner.OS }}-build-${{ hashFiles('**/package.json') }}-${{ hashFiles('**/package-lock.json') }}
      - name: Install NPM packages
        if: steps.node_modules-cache.outputs.cache-hit != 'true'
        run: npm ci
      - name: Build frontend
        run: npm run dev
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '7.4'
      - name: Get Composer Cache Directory 2
        id: composer-cache
        run: |
          echo "::set-output name=dir::$(composer config cache-files-dir)"
      - uses: actions/cache@v2
        id: actions-cache
        with:
          path: '${{ steps.composer-cache.outputs.dir }}'
          key: '${{ runner.os }}-composer-${{ hashFiles(''**/composer.lock'') }}'
          restore-keys: |
            ${{ runner.os }}-composer-
      - name: Cache PHP dependencies
        uses: actions/cache@v2
        id: vendor-cache
        with:
          path: vendor
          key: '${{ runner.OS }}-build-${{ hashFiles(''**/composer.lock'') }}'
      - name: Copy .env
        run: php -r "file_exists('.env') || copy('.env.ci', '.env');" # If .env exist, we use that, if otherwise, copy .env.example to .env and use that instead
      - name: Install Dependencies
        if: steps.vendor-cache.outputs.cache-hit != 'true'
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist
      - name: Generate key
        run: php artisan key:generate
      - name: Change Directory Permissions
        run: chmod -R 777 storage bootstrap/cache
      - name: Clear Config
        run: php artisan config:clear
      - name: Run Migration
        run: php artisan migrate --force
      - name: Create an Archive For Release
        uses: montudor/action-zip@v0.1.0
        with:
          args: zip -X -r github-actions-demo.zip . -x ".git/*" "node_modules/*" "tests/*" ".github/*" composer.* package* phpunit.xml # We excluding git, node_modules, and others not needed in production
      - name: Upload artifact
        uses: actions/upload-artifact@v2
        with:
            name: github-actions-demo
            path: github-actions-demo.zip
      - name: Upload Release
        uses: svenstaro/upload-release-action@v2
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: github-actions-demo.zip
          asset_name: github-actions-demo
          tag: ${{ github.ref }}
          overwrite: true
          body: "Latest Release"

  deploy:
    needs: [build] # The below only runs if the build is succesful
    runs-on: ubuntu-latest
    steps:
      - name: Download Latest ZIP Release
        run: |
          curl -s https://api.github.com/repos/amezmo/github-actions-demo/releases/latest | \
          grep -E "browser_download_url" | grep -Eo 'https://[^\"]*' | xargs wget -O "latest.zip"
      - name: Deploy The App To Amezmo
        run: |
          curl --request POST \
          --url https://api.amezmo.com/api/sites/${{ secrets.SITE_ID }}/deployments \
          --header 'authorization: Bearer ${{ secrets.API_KEY }}' \
          --header 'content-type: multipart/form-data' \
          --form environment=production \
          --form archive=@latest.zip

Let's decipher the above:

Defining Event

name is the name of the workflow, and is displayed on the repo actions page, if the name is omitted, GitHub sets it to the workflow file path relative to the root of the repository.

on specifies how you the workflow should be triggered, in our case, we are triggering the workflow whenever a push occurs on the master branch. A push can be when you commit a change to a branch.

name: Laravel Build and Deploy to Amezmo
on:
  push:
    branches: [master]

Build Job - Setting Up MySQL Service

A job can contain multiple steps and runs in an instance of the GitHub virtual environment, we started with the build, in the runs-on, we set the machine we want the job to run, which is ubuntu in our case.

In other to make MySQL work, we add it to the services, we allowed empty password, specified database, added ports, and use the health check to see if mysql is working as expected.

jobs:
  build:
    runs-on: ubuntu-latest # Machine To Run On
    services: # Service container Mysql
      mysql: # Label used to access the service container
        image: mysql:5.7
        env:
          MYSQL_ALLOW_EMPTY_PASSWORD: yes
          MYSQL_DATABASE:  github_actions_demo
        ports:
          - 33306:3306
        options: >-  # Set health checks to wait until mysql database has started (it takes some seconds to start)
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=3

Checkout From the Repo

Still inside build job, we defined steps with series of actions to execute, the following is used to check-out the repo, so your workflow can access it.

    steps:
      - uses: actions/checkout@v2

Setting Up Node.js

The below yml config setup Node.js with version 16.x (which should be the stable release), we cached the node_modules directory, this way, we can improve performance.

For example, we give the step an id node_modules-cache, the key under the with is used for explictly storing and retriveing the cache, and whenever we want to install NPM packages, this steps.node_modules-cache.outputs.cache-hit != 'true' check if it is a cache miss; that is the cache is not found, we install the npm ndepencies, however, if the cache is an hit; that is the cache is found, it would skip installing npm packages, and uses the one in the cache folder instead (which should increase performance)

      - name: Node.js Setup
        uses: actions/setup-node@v2
        with:
          node-version: 16.x
      - name: Cache node_modules directory
        uses: actions/cache@v2
        id: node_modules-cache
        with:
          path: node_modules
          key: ${{ runner.OS }}-build-${{ hashFiles('**/package.json') }}-${{ hashFiles('**/package-lock.json') }}
      - name: Install NPM packages
        if: steps.node_modules-cache.outputs.cache-hit != 'true'
        run: npm ci
      - name: Build frontend
        run: npm run dev

Setting Up PHP

This setup PHP with version 7.4 and we cached the vendor directory, this way, we can improve performance, the way the caching works is same as the NPM packages except this time, we are caching the vendor directory.

      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '7.4'
      - name: Get Composer Cache Directory 2
        id: composer-cache
        run: |
          echo "::set-output name=dir::$(composer config cache-files-dir)"
      - uses: actions/cache@v2
        id: actions-cache
        with:
          path: '${{ steps.composer-cache.outputs.dir }}'
          key: '${{ runner.os }}-composer-${{ hashFiles(''**/composer.lock'') }}'
          restore-keys: |
            ${{ runner.os }}-composer-
      - name: Cache PHP dependencies
        uses: actions/cache@v2
        id: vendor-cache
        with:
          path: vendor
          key: '${{ runner.OS }}-build-${{ hashFiles(''**/composer.lock'') }}'
      - name: Copy .env
        run: php -r "file_exists('.env') || copy('.env.ci', '.env');" # If .env exist, we use that, if otherwise, copy .env.example to .env and use that instead
      - name: Install Dependencies
        if: steps.vendor-cache.outputs.cache-hit != 'true'
        run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist

In the above yml, I also copied the .env.ci to .env, you should create a file named .env.ci if you haven't already and add the following with your own env values:

APP_NAME=GitHub_Actions_Demo
APP_ENV=local
APP_KEY=
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=33306
DB_DATABASE=github_actions_demo
DB_USERNAME=root

Generating Key, and Running Migrations

Next up, we generate the APP_KEY, change the cache directory permission, cleared config, and lastly we run migration if you have anything to migrate:

      - name: Generate key
        run: php artisan key:generate
      - name: Change Directory Permissions
        run: chmod -R 777 storage bootstrap/cache
      - name: Clear Config
        run: php artisan config:clear
      - name: Run Migration
        run: php artisan migrate --force

Archiving The Artifact:

Moving on, we bundled the artifact (the files generated from the build) into a zip file named github-actions-demo.zip since this would be shipped to our production environment, I excluded the folders that isn't useful in production: git, node_modules, tests, composer, etc

      - name: Create an Archive For Release
        uses: montudor/action-zip@v0.1.0
        with:
          args: zip -X -r github-actions-demo.zip . -x ".git/*" "node_modules/*" "tests/*" ".github/*" composer.* package* phpunit.xml # We excluding git, node_modules, and others not needed in production

Uploading Artifact and Releasing

This uploads the bundled artifact from the virtual environment, we then release it. Learn more about the actions/upload-artifact@v2 and svenstaro/upload-release-action@v2

      - name: Upload artifact
        uses: actions/upload-artifact@v2
        with:
            name: github-actions-demo
            path: github-actions-demo.zip
      - name: Upload Release
        uses: svenstaro/upload-release-action@v2
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: github-actions-demo.zip
          asset_name: github-actions-demo
          tag: ${{ github.ref }}
          overwrite: true
          body: "Latest Release"

That concludes the build job, let's move on to the deploy job

Deploy job - Deploying to Amezmo Using Amezmo API

The below yml config contains steps on how the deploy job should run, we started by needing the [build] job, that is the job would only run if the build job is successful.

We started by downloading the latest released file, we saved the output as latest.zip, to deploy the file to amezmo, we send a POST request to Amezmo API with the downloaded file. This ${{ secrets.SITE_ID }} and this ${{ secrets.API_KEY }}' is what we created in Step 2

  deploy:
    needs: [build] # The below only runs if the build is succesful
    runs-on: ubuntu-latest
    steps:
      - name: Download Latest ZIP Release
        run: |
          curl -s https://api.github.com/repos/amezmo/github-actions-demo/releases/latest | \
          grep -E "browser_download_url" | grep -Eo 'https://[^\"]*' | xargs wget -O "latest.zip"
      - name: Deploy The App To Amezmo
        run: |
          curl --request POST \
          --url https://api.amezmo.com/api/sites/${{ secrets.SITE_ID }}/deployments \
          --header 'authorization: Bearer ${{ secrets.API_KEY }}' \
          --header 'content-type: multipart/form-data' \
          --form environment=production \
          --form archive=@latest.zip

and that is it, whenever a push events occur in the master branch, it triggers the workflow.

Step 5: Testing the Workflow

To test this workflow, you can try making changes to your master branch, you can then check the Actions tab of your repo to see if any workflow is running.

If you are using the cloned sample project, I have intentionally added the following to the route/web.php file:


Route::get('/guest', function () {

return view('welcome');

});

Auth::routes();

Route::get('/home', [App\Http\Controllers\HomeController::class, 'index'])->name('home');

Change the /guest to only: / and commit the changes, you can make the changes directly from GitHub U.I by clicking the pencil icon, once you are done with the changes, commit it and the workflow should get triggered

OR

To make it a bit more realistic, create a dev branch (which would be the branch for developing features before being merged to the master branch):

git checkout -b dev

Open up route/web.php, change /guest to / save the file.

Add the file:

git add routes/web.php

Commit Changes

git commit -m "Update /guest to /"

Push changes to branch

git push origin dev

If git push is successful, compare and merge it to your master branch and that should trigger the workflow, here is how that steps looks:

Any questions?

Resources


Next