Anders Tornblad

All about the code

Label archive for continuous integration

First version of GitHub Webhook handler public on GitHub

Yesterday, I wrote about my efforts for creating an easy-to-use GitHub Webhooks handler in PHP, suitable for shared hosting environments.

After a few hours of making the code a little prettier, it is now public on GitHub. I remade the whole thing into an API style that I would enjoy using. Now, you can hook yourself up to GitHub Webhooks like this:

<?php require_once "mt-github-webhook.php"; // Changes in the QA branch are pushed to the secret password-protected QA web site \MT\GitHub\Webhook::onPushToBranch("qa-testing")-> forChangesInFolder("main-web-site/public_html")-> setGitHubCredentials("github-username", "My5ecretP@ssw0rd")-> pushChangesToFolder("/www/sites/qa.domain.com/public_html"); // Changes in the PRODUCTION branch are pushed to the public-facing web site \MT\GitHub\Webhook::onPushToBranch("production")-> forChangesInFolder("main-web-site/public_html")-> setGitHubCredentials("github-username", "My5ecretP@ssw0rd")-> pushChangesToFolder("/www/sites/www.domain.com/public_html"); ?>

The clone url is: https://github.com/lbrtw/mt-github-webhook.git. Feel free to fork and play around with it.

Automatic deployment on shared server using GitHub webhooks

If you, like me, have a few spare time projects, chances are you don't own or rent a dedicated server for your web hosting. I use Loopia (a Swedish web hosting provider) for my hosting purposes. I use their web hotel service, so I have very little control over file system paths, php modules and such.

On a dedicated server, using GitHub webhooks is pretty straightforward. When your server gets notified of a push or a closed merge request, you can do a simple git clone to create a fresh full copy of the branch you are using for your deploys. On a shared system, without access to the git command-line tools, it gets a little tricker.

I have developed a php-based solution that works for me. My branch and merge setup looks something like this:

  • master : This is the Main development branch
  • dev : This is the Online testing branch
  • vnext : This branch is Used for demonstration purposes, and possibly pilots
  • www : This is the Current stable running version
  • Changes pushed often from master to dev
    • 26 Jan at 16:29: Bug fix
    • 27 Jan at 19:11: New feature
    • 29 Jan at 09:53: Experimenting
    • 30 Jan at 13:49: Bug fix
    • 01 Feb at 11:52: Bug fix
    • 02 Feb at 13:20: Experimenting
    • 03 Feb at 08:41: New feature
    • 04 Feb at 16:17: Bug fix
    • 06 Feb at 11:53: Bug fix
    • 07 Feb at 08:28: New feature
    • 07 Feb at 18:16: Experimenting
    • 09 Feb at 17:32: New feature
    • 10 Feb at 12:53: Bug fix
    • 12 Feb at 12:10: Experimenting
    • 13 Feb at 11:11: New feature
  • Version candidate pushed weekly from dev to vnext
    • 30 Jan at 17:29: Customer demo
    • 05 Feb at 12:52: Internal release
    • 11 Feb at 08:14: Customer demo
  • New version pushed to production when done from vnext to www
    • 07 Feb at 15:49: Live deployment

All development is performed in the master branch. Whenever a feature makes enough progress to be visible or usable (or is completed), or a bug is fixed, I merge to the dev branch. Every now and then, I'm not the only coder making changes. When other coders are done with a feature or a bug-fix, they create a pull request that I approve to perform the merge.

The dev branch is where we test everything internally. We can do experiments, move stuff around, temporarily remove features or add wild and crazy stuff. When the dev branch is good enough for showing to people, we merge to the vnext branch, which is always a little more stable and feels more "done". This is where customers can check out future features and have their say in stuff.

After a couple of rounds of pushing to vnext, it's time to go live. This is done by merging to the www branch.

Continuous Integration and Deployment

Every time something gets pushed into the non-master branches, GitHub posts a message to my webhook handler. The handler reads the message payload to find out what files are changes and what branch is the target. Using this information, it downloads the correct source files from raw.githubusercontent.com and copies to the correct directory of the shared web server file system.

// We are only interested in PUSH events for now $eventName = @$_SERVER['HTTP_X_GITHUB_EVENT']; if ($eventName != 'push') { http_response_code(412); exit("This is not a PUSH event. Aborting..."); } // Read and parse the payload $jsonencodedInput = file_get_contents("php:\/\/input"); $inputData = json_decode($jsonencodedInput); // What branch is this? $branchRef = $inputData->ref; // If I'm interested in the branch, copy all changes, otherwise quit if ($branchRef == 'refs/heads/dev') { copyChanges('/WEB-HOTEL-ROOT/dev.domainname.com/', 'dev', $inputData); } else if ($branchRef == 'refs/heads/vnext') { copyChanges('/WEB-HOTEL-ROOT/vnext.domainname.com/', 'vnext', $inputData); } else if ($branchRef == 'refs/heads/www') { copyChanges('/WEB-HOTEL-ROOT/domainname.com/', 'www', $inputData); } else { http_response_code(412); exit("I'm not interested in the $branchRef branch. Aborting..."); }

The code above is simple enough. Depending on the type of event, and on the name of the branch, the script either exits immediately with a nice error message (that you can read in your GitHub repository's webhook settings page), or calls the copyChanges function, shown below.

function copyChanges($rootFolder, $branchName, $inputData) { // Check all commits involved in this push for changes that I'm interested in $interestingChanges = extractInterestingChangesFromCommits($inputData->commits); $changedPaths = array_keys($interestingChanges); // No interesting changes? Quit! if (count($changedPaths) == 0) { exit("No interesting changes. Goodbye!"); } foreach ($changedPaths as $localPath) { $fullPath = $rootFolder . $localPath; $changeType = $interestingChanges[$localPath]; if ($changeType == 'delete') { // Deleted file - delete it! unlink($fullPath); } else { // Added or modified file - download it! $url = "https://USERNAME:PASSWORD@raw.githubusercontent.com/USERNAME/REPOSITORY/$branchName/$localPath"; $fileContents = file_get_contents($url); if ($fileContents !== false) { file_put_contents($fullPath, $fileContents); } } } }

Actually, the code I use contains some more error checking. It also recursively creates new directories if a file wants to be put in a directory that does not yet exist.

function extractInterestingChangesFromCommits($commits) { // This function returns an array where // the keys are local file paths, and // the values are the type of change // Something like this: // [ // 'path/file.1' => 'add', // 'path/file.2' => 'change', // 'path/file.3' => 'delete' // ] $result = []; foreach ($commits as $commit) { foreach ($commit->added as $added) { $result[$added] = 'add'; } foreach ($commit->modified as $modified) { $result[$modified] = 'change'; } foreach ($commit->deleted as $deleted) { $result[$deleted] = 'delete'; } } return $result; }

That's about it for now. The script has been running and handling deployments for my spare-time projects for a while now, and I feel confident about it. I'll make some more touchups to this script, and then I'll put it on GitHub for you to star. Check in for a link in a few days.