Tutorial for writing a PHP deployment script
Introduction
As a website is being developed, often times it is useful to have a server set up where clients can view the website in development, for project manager to gauge the progress, or simply just for making sure each commit does not break the site! In order to achieve this, the server must pull codes from the latest release branch, for clients viewing, or development branch, for internal purposes. Having to manually do this each time can be quite a burden! In this tutorial, we will set up a way for the codebase to be deployed to the server automatically on each new commit. Our choice of version control system will be Git. However if Git is not the version control system of your choice, then even though the codes posted here won't be directly applicable to other version control systems, the ideas behind them should still be useful! A PHP deployment script, is used to automate the deployment process after code changes are pushed up to a repo. The script handles the pull actions for the hosting servers, allowing them to pull down the changes without manual intervention. The keyword here is automation. Automation provides savings in time, as well as prevents careless mistakes or oversight. An increase in both efficiency and reliability? No wonder a quick Google search turns up so many examples.
Today we are going to walk through the creation of a simple deployment script, with some powerful features that could be customized to fit in with your work flow.
The Basic
Here is a layout of a basic deployment script that achieves automated code deployment, with the option of specifying which branch to pull from by supplying the bn argument. Simply place this script into the public folder of a vhost on the same server as where your websites are hosted and call it with the full path of your targets website as the subdirectories. For example if you placed the script into a vhost named "post-receive.mysrv.com" and your website is hosted in the directory "/var/www/vhosts/mywebsite.mysrv.com/public", you would call "post-receive.mysrv.com/var/www/vhosts/mywebsite.mysrv.com/public" which will pull any new updates to your website.
If you find that you keep all your sites in the same vhosts directory, with the same name for the public folder, there is no reason to have to type out the full directory paths every time.
Let's say you have another website hosted at "/var/www/vhosts/myotherwebsite.mysrv.com/public", we can specify the default parent path as "/var/www/vhosts/" and the default public folder as "/public". Now we can call the script for the two different websites by simply typing "post-receive.mysrv.com/mywebsite.mysrv.com", and "post-receive.mysrv.com/myotherwebsite.mysrv.com".
// and if more than 1 argument is given, treat that as the full "cd-able" path $args = explode('/', $path); if (count($args) === 1) { $working_path = $default_parent_path . $path . $default_public_directory; } elseif (count($args) > 1) { $working_path = ‘/’ . $path; } // Do the routine only if the path is good. // Assumes that origin has already been defined as a remote location. // We reset the head in order to make it possible to switch to a branch that is behind the latest commits. if (!empty($working_path) && file_exists($working_path)) { $output = shell_exec("cd $working_path; git fetch origin; git reset —hard; git checkout $pull_branch_name; git pull origin $pull_branch_name); echo "
$output
"; } /** * Returns the requested url path of the page being viewed. * * Example: * – http://example.com/node/306 returns "node/306". * * See request_path() in Drupal 7 core api for more details */ function request_path() { … }
More Features
Here we discuss many different optional features that either adds more functionality or improves convenience. The code snippets in each example builds upon the previous one and reflects all previous feature additions.
Security key
To make sure your script can only be called by you or those you trust, we are going to add a security key. The security key will be supplied by the user through another URL variable we will call `sk`, and will have to match a pre-set string. To modify the code we simply add the `sk` URL variable and do a check for the variable that it matches with the security key before continuing. This block of code should go at the very beginning of the page.
// Checks the security key to see if it is correct, if not then quits // Currently set to static key 'mysecuritykey' // Example: http://post-receive.mysrv.com/mywebsite.mysrv.com?sk=mysecuritykey if (empty($_GET['sk'])) { header('HTTP/1.1 400 Bad Request', true, 400); echo '
No security key supplied
'; exit; } if ($_GET['sk']!='mysecuritykey') { header('HTTP/1.1 403 Forbidden', true, 403); echo '
Wrong security key supplied
'; exit; }
Tags
This is arguably one of the most versatile functionality we can add to the script. By adding the ability to pull commits based on certain tags, you can adjust this script to fit your work flow. For example, you may want a production server to only pull commits that are tagged with the latest version number. For more information on tags and how they work in Git here is a nice succinct description straight from the Git official documentation. Once again, we had to change the shell commands in order to both retrieve tags information and to pull the appropriate commits. You can setup different tag rules by altering the regular expressions and the comparison done between tags. For example, a rule to only pull commits with tags containing the keyword beta. You can also set different rules for different branches by a switch-case structure based on the `bn` URL variable. // Do the routine only if the path is good if (!empty($working_path) && file_exists($working_path)) { // Fetch and check version numbers from tags $preoutput = shell_exec("cd $working_path; git fetch origin; git fetch origin --tags; git tag"); // Finds an array of major versions by reading a string of numbers that comes after '7.x-' preg_match_all('/(?<=(7\.x-))[0-9]+/', $preoutput, $matches_majver); // Finds the latest major version by taking the version number with the greatest numerical value $majver = max($matches_majver[0]); // Finds an array of minor versions by reading a string of numbers that comes after '7.x-{$majver}.' // where {$majver} is the latest major version number previously found above preg_match_all('/(?<=(7\.x-' . $majver . '.))[0-9]+/', $preoutput, $matches_minver); // Finds the latest minor version by taking the version number with the greatest numerical value $minver = max($matches_minver[0]); // Concaternate version numbers together to form the highest version tag $topver = '7.x-' . $majver . '.' . $minver; echo "
The latest version detected is version $topver
"; $output = shell_exec("cd $working_path; git fetch origin; git reset --hard; git checkout $pull_branch_name; git fetch origin $pull_branch_name; git merge tags/$topver;"); echo "
$output
"; }
Drush
If you're using Drupal as your CMS, chances are you're using Drush. Here we will integrate the script with Drush clear cache commands. The idea is the same as the above features; we start by defining an URL variable `cc` as our drush command variable. The user can execute predetermined drush commands. Clearing cache will clean out all the cached data and force the website to rebuild itself, it is important after code changes in order for those changes to be reflected on the website, especially for the theme layer. --Update-- As Dustin pointed out in the comments below, there is often a need to perform a database update, and for those that work with features as a tool for site building, running a feature revert is a must on any update. The addition of a few new URL variables will give us the option to do so. if (!empty($_GET['cc'])) { switch ($_GET['cc']) { case 'all': shell_exec("cd $working_path; drush cc all"); break; case 'cssplusjs': shell_exec("cd $working_path; drush cc css+js"); break; case 'cssminusjs': shell_exec("cd $working_path; drush cc css-js"); break; } } if (!empty($_GET['up'])) { shell_exec("cd $working_path; drush updatedb -y"); } if (!empty($_GET['fr'])) { shell_exec("cd $working_path; drush fra -y"); }
Github integration
If your repository is hosted on Github, you can make use of their POST callback service by going to Admin>Service Hooks and adding a post commit webhook with the url of the script, complete with the security key and any other arguments. Github will then call the script whenever a new commit is pushed to the repo. BitBucket similarly offers a post commit webhook.Twitter integration
The Github POST service sends along a payload object with quite a bit of useful information with the POST call to our deployment script. By including the twitter-php library by David Grudl we can setup a twitter account to tweet updates with information from the POST payload. You can find the source files as well as the documentation on how to setup your twitter account here on Github. To include this in our script we simply add the below block of code, with the require_once dirname(__FILE__) . '/twitter-php/twitter.class.php'; //insert appropriate values for the keys and tokens $consumerKey = 'consumerkeygoeshere'; $consumerSecret = 'consumersecretgoeshere'; $accessToken = 'accesstokengoeshere'; $accessTokenSecret = 'accesstokensecretgoeshere'; // Tweet on success the total number of commits, latest commit number, which repository and branch received new commits, and who pushed the commit. if (!empty($_POST) && !empty($_POST['payload'])) { $payload = json_decode($_POST['payload']); if (!empty($payload)) { $twitter = new Twitter($consumerKey, $consumerSecret, $accessToken, $accessTokenSecret); $last_commit = end($payload->commits); $twitter->send('[' . $payload->repository->name . ':' . $pull_branch_name . '] ' . count($payload->commits) . ' commit(s) deployed. Last commit: ' . end($payload->commits)->id . ' by ' . end($payload->commits)->author->name . end($payload->commits)->author->email); } }
Here is the script in its entirety
No security key supplied'; exit; } if ($_GET['sk']!='mysecuritykey') { header('HTTP/1.1 403 Forbidden', true, 403); echo '
Wrong security key supplied
'; exit; } $default_pull_branch_name = 'master'; if (empty($_GET['bn'])) { $pull_branch_name = $default_pull_branch_name; } else { $pull_branch_name = $_GET['bn']; } $args = explode('/', $path); if (count($args) === 1) { $working_path = $default_parent_path . $path . $default_public_directory; } elseif (count($args) > 1) { $working_path = ‘/’ . $path; } if (!empty($working_path) && file_exists($working_path)) { $preoutput = shell_exec("cd $working_path; git fetch origin; git fetch origin --tags; git tag"); preg_match_all('/(?<=(7\.x-))[0-9]+/', $preoutput, $matches_majver); $majver = max($matches_majver[0]); preg_match_all('/(?<=(7\.x-' . $majver . '.))[0-9]+/', $preoutput, $matches_minver); $minver = max($matches_minver[0]); $topver = '7.x-' . $majver . '.' . $minver; echo "
The latest version detected is version $topver
"; $output = shell_exec("cd $working_path; git fetch origin; git reset --hard; git checkout $pull_branch_name; git fetch origin $pull_branch_name; git merge tags/$topver;"); echo "
$output
"; } if (!empty($_GET['cc'])) { switch ($_GET['cc']) { case 'all': shell_exec("cd $working_path; drush cc all"); break; case 'cssplusjs': shell_exec("cd $working_path; drush cc css+js"); break; case 'cssminusjs': shell_exec("cd $working_path; drush cc css-js"); break; } } if (!empty($_GET['up'])) { shell_exec("cd $working_path; drush updatedb -y"); } if (!empty($_GET['fr'])) { shell_exec("cd $working_path; drush fra -y"); } if (!empty($_POST) && !empty($_POST['payload'])) { $payload = json_decode($_POST['payload']); if (!empty($payload)) { $twitter = new Twitter($consumerKey, $consumerSecret, $accessToken, $accessTokenSecret); $last_commit = end($payload->commits); $twitter->send('[' . $payload->repository->name . ':' . $pull_branch_name . '] ' . count($payload->commits) . ' commit(s) deployed. Last commit: ' . end($payload->commits)->id . ' by ' . end($payload->commits)->author->name . end($payload->commits)->author->email); } } /** * Returns the requested url path of the page being viewed. * * Example: * – http://example.com/node/306 returns "node/306". * * See request_path() in Drupal 7 core api for more details */ function request_path() { static $path; if (isset($path)) { return $path; } if (isset($_GET['q'])) { // This is a request with a ?q=foo/bar query string. $_GET['q'] is // overwritten in drupal_path_initialize(), but request_path() is called // very early in the bootstrap process, so the original value is saved in // $path and returned in later calls. $path = $_GET['q']; } elseif (isset($_SERVER['REQUEST_URI'])) { // This request is either a clean URL, or 'index.php', or nonsense. // Extract the path from REQUEST_URI. $request_path = strtok($_SERVER['REQUEST_URI'], '?'); $base_path_len = strlen(rtrim(dirname($_SERVER['SCRIPT_NAME']), '\/')); // Unescape and strip $base_path prefix, leaving q without a leading slash. $path = substr(urldecode($request_path), $base_path_len + 1); // If the path equals the script filename, either because 'index.php' was // explicitly provided in the URL, or because the server added it to // $_SERVER['REQUEST_URI'] even when it wasn't provided in the URL (some // versions of Microsoft IIS do this), the front page should be served. if ($path == basename($_SERVER['PHP_SELF'])) { $path = ''; } } else { // This is the front page. $path = ''; } // Under certain conditions Apache's RewriteRule directive prepends the value // assigned to $_GET['q'] with a slash. Moreover we can always have a trailing // slash in place, hence we need to normalize $_GET['q']. $path = trim($path, '/'); return $path; }
That should be enough to get you started on a deployment script. Let us know in the comments if you have any questions, or any other ideas you might have for an improved script!
Credits goes to Brandon Shi, our senior developer here at ImageX Media, for the ideas behind much of these scripts.