Further improving my auto-deployment capabilities.

So I’ve been able to draft new posts, change settings, push to and from both my remote server and github. All from the trusty git client on my development machine. But what happens if I don’t have access to my dev machine, but I want to update my site anyway?

I can always use github’s UI to modify my master pages, but that won’t push it to my webserver.

Or will it? Github has the ability to push events to a remote device via something it calls WebHooks.

Webhooks are relatively simple to implement, but making sure all the permissions are correct can be tricky!


Getting it working.

There are some requirements for webhooks to function correctly:

  1. You need a web server as your webhook endpoint. GitHub will be talking to this.
  2. That’s it!

If that seems overly simple, you’re only slightly correct. Once GitHub has contacted your webserver, you’ll need to take action on the data received. This will be covered later, but the only thing you need to test webhooks is a webserver.

On the endpoint/webserver.

On my webserver, I created a php file called git-deploy-command.php within my /var/www/html folder. This would make it available at https://seantodd.co.uk/git-deploy-command.php

The content of the php file was stolen shamelessly from Miloslav Hůla, with my own additions:

<?php


/**
 * GitHub webhook handler template.
 * 
 * @see  https://developer.github.com/webhooks/
 * @author  Miloslav Hůla (https://github.com/milo)
 */

$hookSecret = 'ENTERSECRET';  # set NULL to disable check


set_error_handler(function($severity, $message, $file, $line) {
	throw new \ErrorException($message, 0, $severity, $file, $line);
});

set_exception_handler(function($e) {
	header('HTTP/1.1 500 Internal Server Error');
	echo "Error on line {$e->getLine()}: " . htmlSpecialChars($e->getMessage());
	die();
});

$rawPost = NULL;
if ($hookSecret !== NULL) {
	if (!isset($_SERVER['HTTP_X_HUB_SIGNATURE'])) {
		throw new \Exception("HTTP header 'X-Hub-Signature' is missing.");
	} elseif (!extension_loaded('hash')) {
		throw new \Exception("Missing 'hash' extension to check the secret code validity.");
	}

	list($algo, $hash) = explode('=', $_SERVER['HTTP_X_HUB_SIGNATURE'], 2) + array('', '');
	if (!in_array($algo, hash_algos(), TRUE)) {
		throw new \Exception("Hash algorithm '$algo' is not supported.");
	}

	$rawPost = file_get_contents('php://input');
	if ($hash !== hash_hmac($algo, $rawPost, $hookSecret)) {
		throw new \Exception('Hook secret does not match.');
	}
};

if (!isset($_SERVER['CONTENT_TYPE'])) {
	throw new \Exception("Missing HTTP 'Content-Type' header.");
} elseif (!isset($_SERVER['HTTP_X_GITHUB_EVENT'])) {
	throw new \Exception("Missing HTTP 'X-Github-Event' header.");
}

switch ($_SERVER['CONTENT_TYPE']) {
	case 'application/json':
		$json = $rawPost ?: file_get_contents('php://input');
		break;

	case 'application/x-www-form-urlencoded':
		$json = $_POST['payload'];
		break;

	default:
		throw new \Exception("Unsupported content type: $_SERVER[HTTP_CONTENT_TYPE]");
}

# Payload structure depends on triggered event
# https://developer.github.com/v3/activity/events/types/
$payload = json_decode($json, true);

switch (strtolower($_SERVER['HTTP_X_GITHUB_EVENT'])) {
	case 'ping':
		echo 'pong';
		break;

	case 'push':
		if ($payload['ref'] === 'refs/heads/master') {
			chdir("/home/sean/sean-blog.dev");
			echo shell_exec("sudo -u sean git pull 2>&1") . "</br>";
			echo shell_exec("sudo -u sean git push production 2>&1") . "</br>";
		}
		break;

//	case 'create':
//		break;

	default:
		header('HTTP/1.0 404 Not Found');
		echo "Event:$_SERVER[HTTP_X_GITHUB_EVENT] Payload:\n";
		print_r($payload); # For debug only. Can be found in GitHub hook log.
		die();
}

?>

In the above script, within case 'push':, I tell my webserver to execute the following commands:

if ($payload['ref'] === 'refs/heads/master') {
			chdir("/home/sean/sean-blog.dev");
			echo shell_exec("sudo -u sean git pull 2>&1") . "</br>";
			echo shell_exec("sudo -u sean git push production 2>&1") . "</br>";
		}

These commands will:

  1. Check that the commit was to the master branch. If not, take no action.
  2. Make my apache user (www-data) change directories to my blog’s working tree
  3. As my normal user (sean) carry out a git pull (to pull my latest commit), followed by a git push to my production server (in this case, itself).

I have leveraged sudo on my own machine for ease of use, but there are many ways of carrying out this sort of functionality, whatever works best for you. The rest of the above code handles the various types of message that github can send to the endpoint.

Of note, is the code: $hookSecret = 'ENTERSECRET'; When a secret is entered into the quotes, the script will check to make sure all requests contain this secret. If the request does, the php continues to execute. This means that only authenticated requests are actioned, providing a measure of security. Now that my endpoint is capable of receiving commands, it’s time to tell GitHub how to talk to my endpoint.


Configuring GitHub to talk webhook

To start, you need to go to your GitHub project settings:

Settings Cog

Browse to webhooks:

Menu - Webhooks

Choose the option to add a webhook, then enter the correct data:

Completed Form

Above, you can see that I’ve entered the url for my endpoint, a secret passphrase to pass authentication and left the other entries as default. Once i have added the endpoint, github makes an initial attempt to contact the webserver. Once this is complete, you can commit to you heart’s content, safe in the knowledge that your changes are being pushed automagically!