Deploy and Release your Applications with Phing

Suppose you have a web application that is installed on many hosts. Each installation may have a custom configuration, and the application is still in active development. You need an easy way to deploy new features and bug fixes to all of hosts.

In his previous article Using Phing, the PHP Build Tool, Shameer gave you a basic understanding of Phing. You learned how to read and write a build file and what its basic components are: project, targets, tasks, and properties.

In this article I show you how to use this knowledge to write a boilerplate build file, one that can be customized and reused in your real-world applications. We’ll use Phing’s Subversion tasks to manage the repository and the FileSync extension to syncronize your local installation with a remote server.

Preparing the Environment

In order to use the Subversion-related tasks, you need the two PEAR packages VersionControl_SVN and NET_FTP. Both can be installed easily with the following commands:

$ sudo pear install VersionControl_SVN
$ sudo pear install NET_FTP

The FileSyncTask extension is a friendly wrapper for the rsync command. It’s maintained by Federico Cargnelutti, and can be downloaded from his site. Once downloaded, copy the file FileSyncTask.php to Phing’s extensions directory (the default path on unix-like systems is /opt/phing/tasks/ext, but your configuration may be different).

I’ve created an empty Subversion repository called “helloworld” and gave it a basic directory structure, with trunk, branches, and tags directories. I checked out the trunk locally and imported my application’s files using the following directory structure:

As you can see, there is a directory named build which will contain the files related to Phing.

The basic build.xml file looks like this:

<?xml version="1.0" encoding="UTF-8"?>
<project name="HelloWorld" default="hello" basedir="../">
 <!-- Load project settings from external file -->
 <property file="build/config/project.properties" />

 <!-- Default empty target -->
 <target name="hello" description="Displays basic project information">
  <echo message="Hello, welcome to ${phing.project.name}!" />
  <echo message="Current environment is: ${project.env}" />
 </target>
</project>

There’s a default target named “hello” that displays basic data, such as the project’s name and the current environment (production, development, staging, etc.).

Instead of declaring a list of properties inside the build file though, Phing is told to load the project’s settings from an external file. The path of this file is relative to the basedir attribute defined in the property tag. By doing this, we can reuse the same file for multiple projects.

The project.properties file is simply a text file which stores settings using a key-value syntax, for example:

ftp.host=host.example.com
ftp.port=21
ftp.username=example
ftp.password=123456
app.home=${ftp.host}/myapp

You can use the same ‘${varname}’ syntax to reference the value of a previously declared properties.

Properties files are stored inside the build/config directory. Also in this directory is a hosts subdirectory, which contains specific client-host settings, and a scripts subdirectory that stores the install script template. Files ending in “-sample.properties” are versioned in the repository and used as templates, while the project-specific and host-specific files are marked as ignored. The build/export directory will contain the files generated by our targets.

In order to run any of the targets we must call Phing from within the build directory. The common syntax is:

$ phing  [-DPropertyName=PropertyValue]

The -D option allows you to redefine the value of a property declared with its override attribute set to “true”.

The ‘deploy’ Target

The deploy target is our continuous integration tool. It syncronizes and deploys the current working copy with a remote host using a specific configuration (eg. servername.properties) stored in build/config/hosts. The target must be able to find the .properties file for the required host, and then connect to the remote host using those settings and perform the sync task.

The target is run with the following command:

$ phing deploy -Dhostname=myhostname

The target’s code is:

<target name="deploy" description="Deploys the current working copy to a remote host using FileSync">
 <!-- Default hostname is empty, must be passed from the command line -->
 <property name="hostname" value="false" override="true" />

 <!-- Set default LISTONLY to false -->
 <property name="listonly" value="false" override="true" />
 <property name="hostfile" value="build/config/hosts/${hostname}.properties" />

 <!-- Check for specific host/env file, if not fail! -->
 <available file="${hostfile}" property="hostfilefound" value="true"/>

 <fail unless="hostfilefound" message="Missing host configuration file (${hostfile})!" />

 <!-- Host file exists so loading... -->
 <property file="${hostfile}" />

 <!-- Get timestamp -->
 <tstamp />

 <!-- Set default VERBOSE flag to TRUE -->
 <if>
  <not>
   <isset property="sync.verbose" />
  </not>
  <then>
   <property name="sync.verbose" value="true" override="true" />
   <echo message="The value of sync.verbose has been set to true" />
  </then>
 </if>

 <!-- Set default DELETE flag to FALSE -->
 <if>
  <not>
   <isset property="sync.delete" />
  </not>
  <then>
   <property name="sync.delete" value="false" override="true" />
   <echo message="The value of sync.delete has been set to false" />
  </then>
 </if>

 <!-- Get auth info, password will be always required -->
 <property name="sync.remote.auth" value="${sync.remote.user}@${sync.remote.host}" />

 <!-- Perform Sync -->
 <!-- See: http://fedecarg.com/wiki/filesynctask -->
 <taskdef name="sync" classname="phing.tasks.ext.FileSyncTask" />
 <sync
  sourcedir="${sync.source.projectdir}" 
  destinationdir="${sync.remote.auth}:${sync.destination.projectdir}" 
  listonly="${listonly}" 
  excludefile="${sync.exclude.file}" 
  delete="${sync.delete}" 
  verbose="${sync.verbose}" />
</target>

First the hostname property is defined as overridable and assigned an arbitrary default value (I chose “true”). Then the listonly property is defined with a default value. This property is used by the sync task, and if it’s set to true then the task will only display a list of files that should be processed but will not perform the actual sync.

The next three statements define the path of the host settings file and use the available task to set the hostfilefound property if the file is present. If it isn’t, the property won’t be set and the fail task will abort the script with the provided error message. If the host properties file is present then it’s loaded; this file contains a set of settings for the task grouped using the prefix “sync.”. They are all self-explanatory, but one worth paying special attention to is sync.delete: if set to true, all remote files that are not present in the local copy will be deleted. I recommend always setting it false unless you have a valid reason for it to be true.

Next we can see two other examples of how powerful Phing is: we use the if/then syntax to define the value of the sync.delete and sync.verbose properties.

The last part is the actual deploy task. The taskdef statement tells Phing that sync is a custom task and provides the path of the PHP file to load, and the parameters for this task are loaded from the host file. The excludefile attribute points to a text file containing a list of patterns to exclude from syncronization, one pattern per line, using rsync‘s syntax.

Note: if your remote server use an SSH identity file you must set the identityfile attribute with the full path of your key file.

The ‘prepare’ Target

The prepare target’s job is to create a snapshot of the current trunk and tag it for release. The current trunk is copied under the tags/nameoftag directory of the repository (for example, tags/1.0.1).

The code for prepare looks like:

<target name="prepare" description="Prepares a tag in the remote repository">

 <!-- Ask for a tag label to copy the current trunk -->
 <property name="tagLabel" value="false" override="true" />

 <!-- The tag name cannot be empty! -->
 <if>
  <isfalse value="${tagLabel}"/>
  <then>
   <fail message="Invalid tag name!" />
  </then>
 </if>

 <echo>Preparing tag ${tagLabel}...</echo>

 <!-- Copy trunk to the new tag under tags/tagLabel -->
 <svncopy
  force="true"
  nocache="true"
  repositoryurl="${svn.repository.url}/trunk"
  todir="${svn.repository.url}/tags/${tagLabel}"
  username="${svn.repository.username}"
  password="${svn.repository.password}"
  message="Tag release ${tagLabel}" />

 <!-- Switch the working copy repo to the newly created tag -->
 <svnswitch
  repositoryurl="${svn.repository.url}/tags/${tagLabel}"
  username="${svn.repository.username}"
  password="${svn.repository.password}"
  todir="." />

 <!-- Here you can perform any kind of editing: generate documentation, export SQL files, ecc -->
  <touch file="README.txt" />

 <!-- Commit changes -->
 <svncommit
  workingcopy="."
  message="Finish editing tag ${tagLabel}" />
 <echo message="Committed revision: ${svn.committedrevision}"/>

 <!-- Reset working copy repo to trunk -->
 <svnswitch
  repositoryurl="${svn.repository.url}/trunk" />

 <echo msg="Tag ${tagLabel} completed!" />
</target>

In the first couple lines we set a default value for the tag label because we want it to be passed from the command line, then we make sure that this value is not empty using the if/isFalse statement.

Then, we use the svncopy task to copy our trunk to the desired tag directory and the svnswitch task to tell Subversion that we are now working on the newly created tag. From this point on we can make any modification to our files, such as updating a README file or editing configuration files with suitable defaults. After all of the edits are performed, we use svncommit to save the changes and another svnswitch to reset our working copy back to the previous state.

The ‘release’ Target

The release target is our packaging tool. It starts by taking the trunk or the selected tag from the repository and performs the following actions with it:

  • export the tag files somewhere (by default to build/export)
  • create a TAR.GZ package from the exported files
  • compute the SHA1 digest of the compressed file
  • generate the installation script using the install.template.sh file
  • upload the files to the release server

The code for release is:

<target name="release" description="Exports the trunk or the given tag along with install scripts and FTP uploads">
 
 <property name="release" value="trunk" override="true" />
 
 <echo message="Creating package for '${release}'" />
 
 <!-- Process repository path for trunk or tag -->
 <if>
  <equals arg1="${release}" arg2="trunk" />
  <then>
   <property name="repo-path" value="${release}" override="true" />  
  </then>
  <else>
   <property name="repo-path" value="tags/${release}" override="true" /> 
  </else>
 </if>
 
 <!-- Export selected branch/tag from remote repository -->
 <svnexport 
  repositoryurl="${svn.repository.url}/${repo-path}"
  force="true"
  username="${svn.repository.username}"
  password="${svn.repository.password}"
  nocache="true"
  todir="${svn.export.basedir}/${release}" />
 
 <!-- Do other custom editing here... -->
 
 <!-- Create TAR archive -->
 <tar
  destfile="${svn.export.basedir}/${phing.project.name}-${release}.tar.gz"
  compression="gzip">
  <fileset dir="${svn.export.basedir}/${release}">
   <include name="*" />
  </fileset>
 </tar>
 
 <!-- Delete Temporary Export directory -->
 <delete
  dir="${svn.export.basedir}/${release}"
  includeemptydirs="true"
  verbose="false"
  failonerror="true" />

 <!-- Compute SHA1 digest -->
 <property name="hash" value="empty" />
 <filehash
  file="${svn.export.basedir}/${phing.project.name}-${release}.tar.gz"
  hashtype="1"
  propertyname="hash" />
 <echo msg="SHA1 Digest = ${hash}" />
 
 <echo msg="Files copied and compressed in build directory OK!" />
 
 <!-- Prepare install.sh, backup.sh and update.sh scripts -->
 <copy todir="${svn.export.basedir}" overwrite="true">
  <mapper type="glob" from="*.template.sh" to="*.sh"/>
  <fileset dir="./build/config/scripts">
   <include name="*.sh" />
  </fileset>
  <filterchain>
   <replacetokens begintoken="##" endtoken="##">
    <token key="SRCURL" value="${http.srcurl}/${release}/" />
    <token key="FILENAME" value="${phing.project.name}-${release}" />
    <token key="FILEXT" value="tar.gz" />
    <token key="HASH" value="${hash}" />
    <token key="APPNAME" value="${phing.project.name}" />
    <token key="APPVERSION" value="${release}" />
   </replacetokens>
  </filterchain>
 </copy>
 
 <!-- Upload the generated file(s) to FTP -->
 <property name="upload" value="false" override="true" />
 <if>
  <equals arg1="${upload}" arg2="true" />
  <then>
   <echo msg="Uploading to FTP server for release..." />
   <ftpdeploy 
    host="${ftp.host}" 
    port="${ftp.port}" 
    username="${ftp.username}" 
    password="${ftp.password}"
    dir="${ftp.dir}/${release}" 
    passive="${ftp.passive}"
    mode="${ftp.mode}">
    <fileset dir="${svn.export.basedir}">
     <include name="${phing.project.name}-${release}.tar.gz" />
     <include name="install.sh" />
    </fileset>
   </ftpdeploy>
 
   <echo>Now you can run: wget ${http.srcurl}/${release}/install.sh &amp;&amp; sh install.sh [stage|local|prod] 2>&amp;1 > ./install.log</echo>
  </then>
 </if>
 
 <echo msg="Done!" /> 
</target>

The first few lines deal with input parameters, with which we specify the default value “trunk” for the release to export and using the if statement to calculate the source repository path.

The svnexport task exports the given files directly from the repository (not the working copy) into our destination directory. A temporary directory is created named “AppName-ReleaseName”.

The tar task generates the package file from the temporary export directory using an internal fileset as source. The delete task, which is optional, deletes the temporary files.

We then use the filehash task to generate a SHA1 digest for the packaged file (the other option is MD5) and store it in the hash property.

The copy task is called to copy the template installer script into our export directory and uses some very powerful Phing resources. mapper is a filter selection and transformation tool applied to filenames. The files selected with the given fileset are processed by the mapper before the copy. In this case, the files matching “*.template.sh” are renamed with the extension “.sh” in the destination directory.

Another powerful feature used here is filterchain. Phing filters are used to transform the content of files during the operation of another task. In this case the “parent” task is copy. The filter used here is replacetokens, which replaces in each file a set of defined template variables with a dynamically generated value. It’s used here to insert the specific release details (name, version, hash, url, etc.) into the install script.

The final touch is the ftpdeploy task, triggered by the upload property. This task uploads our package file and the install script to the remote server and displays the URL to use for your installation. If there are no errors you can run the command:

$ wget http://www.yoursite.com/helloworld/releases/trunk/install.sh && sh install.sh

The commands download and verify the package file and then performs the installation tasks you specified.

Summary

We’ve seen a lot of useful Phing features here, but there are many more to discover. You can use the buildfile “as is” to deploy your current and future projects, or expand it by adding more features like support for unit testing, Git, database processing, and even custom extensions. As always, the official documentation will be your best place to start.

Image via 1971yes / Shutterstock

Free book: Jump Start HTML5 Basics

Grab a free copy of one our latest ebooks! Packed with hints and tips on HTML5's most powerful new features.

  • http://rubikin.com Raine

    seem like may files are missing such as install.template.sh or some sample hostname.properties. You mentioned about them but you don’t show how they should look like anywhere?

  • http://peterdrinnan.com Peter Drinnan

    The hello world example file is perfect for me as I am simply trying to get a grasp of this whole Phing. I’ve been using Springloops and Beanstalk for deployments but at some point I figure its time to let go of the handrail and figure out the deployment situation myself. Phing seems like the best choice.
    Thanks for the tutorial.

  • Jesús

    Hi Vivo!
    First of all, thanks for the tutorial, it’s really great!!
    Secondly, I have a doubt: Is the release server (http://www.yoursite.com/) the same as the production server or it’s only a place to put the releases and then you have to go to the production server and type manually the command for install the release ($ wget http://www.yoursite.com/helloworld/releases/trunk/install.sh && sh install.sh)?
    Greetings

    • http://www.vtardia.com/ Vito Tardia

      Hi Jesús, thank you!
      The release server can be anything you want, a web space where you can upload the code and install scripts. It is configured inside the project property file and not the host, so can be different from any other production locations.


      Vito