Do you want set up a CI/CD process using GitHub Actions?
This is a walk-through basic setup to automate your integrations and deployments for your projects.
GitHub Actions
GitHub Actions allows for your code can be built, tested and deployed from GitHub. Let's explore an approach in managing CI/CD processes using GitHub Actions.
Set-up - having a plan
Creating a basic CI/CD setup for a Laravel application. The CI/CD setup will monitor push and pull requests made to our repository. Therefore being able to:
- Run tests on pushes
- Run tests on pull requestDeploy
- Deploy to staging server when push is on staging branch
- Deploy to production server when push is on master
Workflow?
A workflow provides a sequence of steps required to complete a CI/CD process. It will defined within the repository and subsequently you might have guessed that it will be committed as part of the repository. So, when commiting / pushing to GitHub, GitHub Actions will detect the workflow and kick into action. Immediately parse the workflow and begin to action your CI/CD process based on the defined instructions. Workflows are written with YAML and stored inside .github/workflows directory of your project root.
Configuring workflows
Pull request workflow: tests
If you are part of a team working on the same project, then this workflow is when its leverage shows itself. Benefits include being able to run checks whenever a pull request is created. Much better than waiting post merge to test it. This is achieved by defining a workflow that will run when a pull request is made.
The following example should be saved as a YAML file in the .github/workflows directory as pr_workflow.yml. An example of this type of workflow:
name: PR WorkFlow on: pull_request: branches: - master - staging jobs: app-checks: runs-on: ubuntu-latest services: mysql: image: mysql:5.7 env: MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: test_db ports: - 3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v1 - name: Copy .env run: php -r "file_exists('.env') || copy('.env.example', '.env');" - name: Install Composer Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist - name: Install NPM Dependencies run: npm install - name: Generate key run: php artisan key:generate - name: Execute tests (Unit and Feature tests) using PHPUnit env: DB_PORT: ${{ job.services.mysql.ports[3306] }} run: ./vendor/bin/phpunit - name: Execute tests (Unit and Feature tests) using JEST run: node_modules/.bin/jest
name: PR WorkFlow on: pull_request: branches: - master - staging
As you might have picked up, going past the name of the workflow - PR is short for pull request, the GitHub Actions are defined to run when a pull request is made to the master and staging branch.
jobs: app-checks: runs-on: ubuntu-latest services: mysql: / ... / steps: / ... /
A job named app-checks works through it's purpose, which is to run both tests using Jest and PHPUnit. Through instructing Github Actions to include MySQL as a service when setting up the action. Whereas, the steps set out the order to be performed.
Push workflow: tests
Whenever a change is made, best practice is to ensure that the project is good to go. And is cross-checked using tests that confirm that the project broken due to change. The following example needs to be saved in the .github/workflows directory as push_workflow.yml:
name: Push Workflow on: push: branches: - master - staging jobs: app-checks: runs-on: ubuntu-latest services: mysql: image: mysql:5.7 env: MYSQL_ALLOW_EMPTY_PASSWORD: yes MYSQL_DATABASE: test_db ports: - 3306 options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - uses: actions/checkout@v1 - name: Copy .env run: php -r "file_exists('.env') || copy('.env.example', '.env');" - name: Install Composer Dependencies run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist - name: Install NPM Dependencies run: npm install - name: Generate Key run: php artisan key:generate - name: Execute tests (Unit and Feature tests) via PHPUnit env: DB_PORT: ${{ job.services.mysql.ports[3306] }} run: vendor/bin/phpunit - name: Execute tests (Unit and Feature tests) via JEST run: node_modules/.bin/jest build-js-production: name: Build JavaScript/CSS for PRODUCTION Server runs-on: ubuntu-latest needs: app-checks if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v1 - name: NPM Build run: | npm install npm run prod - name: Put built assets in Artifacts uses: actions/upload-artifact@v1 with: name: assets path: public build-js-staging: name: Build JavaScript/CSS for STAGING Server runs-on: ubuntu-latest needs: app-checks if: github.ref == 'refs/heads/staging' steps: - uses: actions/checkout@v1 - name: NPM Build run: | npm install npm run dev - name: Put built assets in Artifacts uses: actions/upload-artifact@v1 with: name: assets path: public deploy-production: name: Deploy Project to production server runs-on: ubuntu-latest needs: [build-js-production, app-checks] if: github.ref == 'refs/heads/master' steps: - uses: actions/checkout@v1 - name: Fetch built assets from Artifacts uses: actions/download-artifact@v1 with: name: assets path: public - name: Setup PHP uses: shivammathur/setup-php@master with: php-version: 7.4 extension-csv: mbstring, bcmath - name: Composer install run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist - name: Setup Deployer uses: atymic/deployer-php-action@master with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} ssh-known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - name: Deploy to PRODUCTION Server env: DOT_ENV: ${{ secrets.DOT_ENV_PRODUCTION }} run: dep deploy production --tag=${{ env.GITHUB_REF }} -vvv deploy-staging: name: Deploy Project to staging server runs-on: ubuntu-latest needs: [build-js-staging, app-checks] if: github.ref == 'refs/heads/staging' steps: - uses: actions/checkout@v1 - name: Fetch built assets from Artifacts uses: actions/download-artifact@v1 with: name: assets path: public - name: Setup PHP uses: shivammathur/setup-php@master with: php-version: 7.4 extension-csv: mbstring, bcmath - name: Composer install run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist - name: Setup Deployer uses: atymic/deployer-php-action@master with: ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} ssh-known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }} - name: Deploy to Prod env: DOT_ENV: ${{ secrets.DOT_ENV_STAGING }} run: dep deploy staging --tag=${{ env.GITHUB_REF }} -vvv
When there is a push on the repository, the Push Workflow will run frontend tests with Jest and backend tests with PHPUnit. However, you will have picked up on some significant changes between this push workflow and the previous pull request workflow. In a nut shell, there are more jobs – four in fact. These four jobs are build-js-production, build-js-staging, deploy-staging, and deploy-production. Yes they are paired to production and staging jobs.
Four extra jobs – what are they doing?
Grouping together build-js-production and build-js-staging, there purpose is to build the JavaScript assets, upload them to the GitHub Actions artifacts and be available when deployed.
To determine which job GitHub Actions is going to be deployed, this is set by an if statement. Positioned in the deploy-production and deploy-staging jobs. The grouping becomes apparent here where the pairing of JS and workflow are managed. The production job needs
[build-js-production, app-checks]
... which informs GitHub Actions that deploy-production requires build-js-production and app-checks jobs to run successfully. Therefore, the deploy-staging job will run after both build-js-staging and app-checks runs and exited without any error. This ensures if the test or asset build fails, then the deployment doesn’t proceed. The same goes for deploy-staging. See below highlighted text:
deploy-production: name: Deploy Project to production server runs-on: ubuntu-latest needs: [build-js-production, app-tests] if: github.ref == 'refs/heads/master'
For staging the line is simply – refs/heads/staging:
deploy-staging: name: Deploy Project to staging server runs-on: ubuntu-latest needs: [build-js-staging, app-tests] if: github.ref == 'refs/heads/staging'
name: Fetch built assets from Artifacts uses: actions/download-artifact@v1 with: name: assets path: public
These instructions download the uploaded assets from the artifacts into the public folder of the project. Whereas, to install composer dependencies, a step that is required to run Deployer later.
- name: Composer install run: composer install -q --no-ansi --no-interaction --no-scripts --no-suggest --no-progress --prefer-dist
Setup PHP action
To setup the PHP Version to use and the extensions we need:
- name: Setup PHP uses: shivammathur/setup-php@master with: php-version: 7.4 extension-csv: mbstring, bcmath
Deployer GitHub action
Set up Deployer with atymic/deployer-php-action@master and is used with the SSH_PRIVATE_KEY and SSH_KNOWN_HOSTS.
- name: Setup Deployer
uses: atymic/deployer-php-action@master
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
ssh-known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }}
- name: Deploy to Prod
env:
DOT_ENV: ${{ secrets.DOT_ENV_STAGING }}
run: dep deploy staging --tag=${{ env.GITHUB_REF }} -vvv
- name: Deploy to Prod
env:
DOT_ENV: ${{ secrets.DOT_ENV_PRODUCTION }}
run: dep deploy staging --tag=${{ env.GITHUB_REF }} -vvv
Now let's initiate the deploy process. Deployer will upload the project to the server. The difference between the two parts:
- secrets.DOT_ENV_PRODUCTION;
- secrets.DOT_ENV_STAGING
So different environment variables for different deployments can be injected. In most situations, different environment variables for production and staging.
Server credentials
If you haven't already you will need to generate a SSH key. How come? GitHub actions needs access to the server to deploy the changes. Do this using the private key for your server. To do this, you will need to generate an SSH key if you haven’t done that already, log in to your server and run:
ssh-keygen
Work through a few prompts and you can accept the default values. Make sure that there is no passphrase given to your SSH key during the prompt setup.
id_rsa
Two files named id_rsa.pub and id_rsa will be generated for you in the ~/.ssh directory. Next, copy the content of id_rsa, not id_rsa.pub. id_rsa.pub contains your public key while id_rsa contains your private key:
cat ~/.ssh/id_rsa
You need to add the public key generated by SSH keygen to the authorized_keys so that any connection attempted using the private key will be allowed. This is done by running this command:
cat ~/.ssh/id_rsa.pub >> ~/.ssh/authorized_keys
The command above will produce no output so do not worry if nothing happens after running the command.
Adding SSH to GItHub
Steps to add:
- In GitHub, go to the corresponding project, click the Settings tab of the project;
- Click on Secrets, then click Actions;
- Create a new secret by clicking New repository secret;
- Enter a name that corresponds to what has been defined in the Workflow. Such as SSH_PRIVATE_KEY and then put the content you copied as the value;
Obtain the SSH Known Hosts of the server and also add it to the Secrets section. From your local machine you have to run to get the ssh known hosts:
ssh-keyscan rsa -t {server_ip_address}
Copy the output of this command – {server_ip_address} sh-rsa, add it to the secrets with the name SSH_KNOWN_HOSTS.
Repeat this process for the staging and the production servers. There are other ways to resolve this, but I'll keep it to this step for now.
Application environment files
Add the environment variables in the secret as per what was covered previously in 'Adding SSH to GItHub'. This step will update the environment variables when the action is running. How? By compiling the environment variables needed by the app on the server into a .env file.
Then we will copy the content of this file the add to secrets with the name:
`DOT_ENV_STAGING` and `DOT_ENV_PRODUCTION`
Preparing Deployer
To initiate the deployment by using Deployer. To use this, set it up locally in our project by running:
composer require deployer/deployer deployer/recipes
The package deployer/deployer is the main Deployer project while deployer/recipes includes components to configure Deployer for specific project(s) and tools like Laravel, Symfony, RSync, and etc...
Configure Deployer by creating a file named deploy.php at the root of the project:
<?php namespace Deployer; require 'recipe/laravel.php'; require 'recipe/rsync.php'; set('application', 'My App'); set('ssh_multiplexing', true); set('rsync_src', function () { return __DIR__; }); add('rsync', [ 'exclude' => [ '.git', '/.env', '/storage/', '/vendor/', '/node_modules/', '.github', 'deploy.php', ], ]); task('deploy:secrets', function () { file_put_contents(__DIR__ . '/.env', getenv('DOT_ENV')); upload('.env', get('deploy_path') . '/shared'); }); host('myapp.io') ->hostname('{ip_address}') ->stage('production') ->user('root') ->set('deploy_path', '/var/www/my-app'); host('staging.myapp.io') ->hostname('{ip_address}') ->stage('staging') ->user('root') ->set('deploy_path', '/var/www/my-app-staging'); after('deploy:failed', 'deploy:unlock'); desc('Deploy the application'); task('deploy', [ 'deploy:info', 'deploy:prepare', 'deploy:lock', 'deploy:release', 'rsync', 'deploy:secrets', 'deploy:shared', 'deploy:vendors', 'deploy:writable', 'artisan:storage:link', 'artisan:view:cache', 'artisan:config:cache', 'artisan:migrate', 'artisan:queue:restart', 'deploy:symlink', 'deploy:unlock', 'cleanup', ]);
Take note to replace your IP Address in the braces {ip_address}.
Creating the deploy.php file and commit / push the changes to the master branch on GitHub. Go to your project in GitHub, click on the Actions tab to monitor the running actions. Once you do click on it, you'll see that your push has triggered the workflow and the process has started already. This process will run in stages as defined earlier in the workflowYAML file.