Git post-receive hook to deploy WordPress and plugins as submodules

In a previous blog post I discussed how best to manage file and folder permissions when deploying with Git, but today I’ll show a specific example of what that post-receive hook might look like for a WordPress project that uses submodules.

I have three servers that I can deploy to, but the post-receive hook only deploys a project when it encounters the specified branch as defined per server:

  1. QA waits for a release branch
  2. Staging waits for the master branch
  3. Production waits for the master branch

Other than this small difference, the post-receive hook is identical on all three servers to reduce maintenance.

Lastly, each server has two repositories per project:

  1. Bare repository – storage unit that uses a post-receive hook to deploy the project.
  2. Working repository – web root that will serve the project to the end-user.

This method has several benefits:

  1. View a combined list of all version-controlled projects via the /opt/repositories directory.
  2. Recover a corrupted web root by cloning a fresh copy from the origin.
  3. Prevent some issues that may occur during the deployment, since it will fail before post-receive hook is fired.

1. Initial setup

I use a wrapper so that I don’t have to update the actual post-receive hook in all my bare repositories each time I make a change to the script.

This is what you’ll find in the post-receive hook:

1
source /opt/scripts/git/hooks/post-receive.sh FULL_DOMAIN ROOT_DOMAIN SUB_DOMAIN
source /opt/scripts/git/hooks/post-receive.sh FULL_DOMAIN ROOT_DOMAIN SUB_DOMAIN

You can then just pass the host-specific details as arguments, for example:

  • FULL_DOMAIN – test.domain.com
  • ROOT_DOMAIN – domain.com
  • SUB_DOMAIN – test

I actually have another script that creates the bare repository, copies the above template into the repository’s hooks directory, sets execute permissions accordingly, and then dynamically replaces the placeholder arguments.

2. Listening for desired branch

The post-receive.sh script that the post-receive hook calls should only perform an action if the desired branch was pushed, the reason being that we don’t want to pull or checkout a branch into a working directory just to then overwrite it with another subsequent branch in the same deployment. We’ll want to ignore other branches that may accidentally or inherently (tags) get pushed.

That’s not to say you couldn’t tweak this to match whatever procedures and protocols you have in place for the function of your servers.

We’ll start our script with the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
if [[ (-n $1) && (-n $2) && (-n $3) ]]; then
 
    # Set path to htdocs
    htdocs="/var/www/domains/$2/$3/htdocs"
 
    # For each branch that was pushed
    while read oldrev newrev refname
    do
 
        # Get branch name
        branch=$(git rev-parse --symbolic --abbrev-ref $refname)
        echo "> Received $branch branch"
 
        # If branch is release branch
        if [[ "$branch" == "release/"* ]]; then
            ...
        fi
    done
 
# Print arguments to debug
else
    echo "Not all required variables have values:"
    echo "> FULL_DOMAIN: $1"
    echo "> ROOT_DOMAIN: $2"
    echo "> SUB_DOMAIN: $3"
fi
if [[ (-n $1) && (-n $2) && (-n $3) ]]; then

	# Set path to htdocs
	htdocs="/var/www/domains/$2/$3/htdocs"

	# For each branch that was pushed
	while read oldrev newrev refname
	do

		# Get branch name
		branch=$(git rev-parse --symbolic --abbrev-ref $refname)
		echo "> Received $branch branch"

		# If branch is release branch
		if [[ "$branch" == "release/"* ]]; then
			...
		fi
	done

# Print arguments to debug
else
	echo "Not all required variables have values:"
	echo "> FULL_DOMAIN: $1"
	echo "> ROOT_DOMAIN: $2"
	echo "> SUB_DOMAIN: $3"
fi
  1. Ensure that FULL_DOMAIN ($1), ROOT_DOMAIN ($2), and SUB_DOMAIN ($3) are set, and if not, let the user know.
  2. Set the htdocs path, which is where the code will ultimately be cloned or pulled into via the post-receive hook.
  3. Loop through all the branches that have been pushed.
  4. Continue with the script if the branch name matches the one we’re looking for.

The example above is for the QA server. "$branch" == "release/"* will match all of my releases e.g. release/1.0.0, release/1.1.0, release/1.2.1, etc.

For the staging and production servers you could change this to "$branch" == "master".

3. Determining first or subsequent deployment

If it’s the first time I’m deploying a project, I’ll clone it into the web root, but if it’s not, then I simply pull or checkout the branch that was pushed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# If branch is release branch
if [[ "$branch" == "release/"* ]]; then
    echo "> Processing $branch branch"
 
    # Unset global GIT_DIR so script can leave repository
    unset GIT_DIR
    echo "> Unset GIT_DIR"
 
    # Move into htdocs
    cd $htdocs
    echo "> Moved into $htdocs"
 
    # If htdocs is empty
    if find . -maxdepth 0 -empty | read; then
        echo "> Determined htdocs is empty"
        ...
 
    # If htdocs is not empty    
    else
        echo "> Determined htdocs contains files"
        ...
    fi
fi
# If branch is release branch
if [[ "$branch" == "release/"* ]]; then
	echo "> Processing $branch branch"

	# Unset global GIT_DIR so script can leave repository
	unset GIT_DIR
	echo "> Unset GIT_DIR"

	# Move into htdocs
	cd $htdocs
	echo "> Moved into $htdocs"

	# If htdocs is empty
	if find . -maxdepth 0 -empty | read; then
		echo "> Determined htdocs is empty"
		...

	# If htdocs is not empty	
	else
		echo "> Determined htdocs contains files"
		...
	fi
fi

We look at the web root and if there’s anything in it, we know we’ve deployed before, but if it’s empty, we know this is the first time.

4. Cloning, pulling, or checking out

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# If htdocs is empty
if find . -maxdepth 0 -empty | read; then
    echo "> Determined htdocs is empty"
 
    # Clone branch from origin repository into htdocs
    git clone /opt/repositories/$1.git -b $branch .
    echo "> Cloned origin/$branch branch from $1.git repository into htdocs"
 
    # Create empty file to remember created date
    touch $htdocs/.created
    echo "> Created .created file in htdocs"
 
# If htdocs is not empty    
else
    echo "> Determined htdocs contains files"
 
    # Get HEAD of working directory
    current_branch=$(git rev-parse --abbrev-ref HEAD)
    echo "> Determined working directory is on $current_branch branch"
 
    # If branch matches HEAD of working directory
    if [ "$branch" == "$current_branch" ]; then
        echo "> Determined updates affect current branch"
 
        # Fetch and merge changes into htdocs
        git pull origin $branch
        echo "> Pulled origin/$branch into $branch branch in htdocs"
 
    # If branch does not match HEAD of working directory
    else
        echo "> Determined updates belong to new branch"
 
        # Fetch changes from origin
        git fetch origin
        echo "> Fetched changes from origin"
 
        # Checkout new branch
        git checkout $branch
        echo "> Checked out $branch branch in htdocs"
    fi              
 
    # Create or update empty file to remember last updated date
    touch $htdocs/.updated
    echo "> Updated .updated file in htdocs"
fi
# If htdocs is empty
if find . -maxdepth 0 -empty | read; then
	echo "> Determined htdocs is empty"

	# Clone branch from origin repository into htdocs
	git clone /opt/repositories/$1.git -b $branch .
	echo "> Cloned origin/$branch branch from $1.git repository into htdocs"

	# Create empty file to remember created date
	touch $htdocs/.created
	echo "> Created .created file in htdocs"

# If htdocs is not empty	
else
	echo "> Determined htdocs contains files"

	# Get HEAD of working directory
	current_branch=$(git rev-parse --abbrev-ref HEAD)
	echo "> Determined working directory is on $current_branch branch"

	# If branch matches HEAD of working directory
	if [ "$branch" == "$current_branch" ]; then
		echo "> Determined updates affect current branch"

		# Fetch and merge changes into htdocs
		git pull origin $branch
		echo "> Pulled origin/$branch into $branch branch in htdocs"

	# If branch does not match HEAD of working directory
	else
		echo "> Determined updates belong to new branch"

		# Fetch changes from origin
		git fetch origin
		echo "> Fetched changes from origin"

		# Checkout new branch
		git checkout $branch
		echo "> Checked out $branch branch in htdocs"
	fi				

	# Create or update empty file to remember last updated date
	touch $htdocs/.updated
	echo "> Updated .updated file in htdocs"
fi
  1. Clone the project if the web root is empty.
  2. Create a timestamp (.created) for when the project was first deployed.
  3. Pull the project if the HEAD‘s branch gets pushed again, so that we can merge in any changes we might have made.
  4. Fetch and checkout the project if an entirely new branch was pushed.
  5. Create a timestamp (.updated) for when the project was updated.

The timestamps are purely for informational purposes. I use the updated timestamp sometimes to verify that the code was indeed deployed into the working directory.

 5. Updating all submodules

If we have WordPress and plugins setup as submodules, we need to make sure that they get updated on your deployment server if they were updated in the origin.

1
2
3
4
5
6
7
# Fetch commits and tags for each submodule
git submodule foreach git fetch --tags
echo "> Fetched commits and tags for each submodule"
 
# Update all submodules
git submodule update --init --recursive
echo "> Initialized and updated all submodules"
# Fetch commits and tags for each submodule
git submodule foreach git fetch --tags
echo "> Fetched commits and tags for each submodule"

# Update all submodules
git submodule update --init --recursive
echo "> Initialized and updated all submodules"

6. Creating web server writable directories

This part will only work correctly if you followed my previously mentioned blog post. We’re creating necessary non-version-controlled directories that WordPress or plugins may need to write to.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# If website is WordPress powered
if [ -d "$htdocs/wordpress" ]; then
    echo "> Determined website runs WordPress"
 
    # If uploads directory does not exist
    if [ ! -d "$htdocs/wp-content/uploads" ]; then
        echo '> Determined htdocs/wp-content/uploads does not exist'
 
        # Create uploads directory
        mkdir $htdocs/wp-content/uploads
        echo '> Created uploads in htdocs/wp-content'
 
        # Give Apache permissions to write to uploads
        chmod g+w $htdocs/wp-content/uploads
        echo '> Added write permissions to htdocs/wp-content/uploads'
    fi
 
    # If w3-total-cache directory exists
    if [ -d "$htdocs/wp-content/plugins/w3-total-cache" ]; then
        echo "> Determined WordPress has W3 Total Cache installed"
 
        # If cache directory does not exist
        if [ ! -d "$htdocs/wp-content/cache" ]; then
            echo "> Determined htdocs/wp-content/cache does not exist"
 
            # Create cache directory for cache files
            mkdir $htdocs/wp-content/cache
            echo "> Created cache in htdocs/wp-content"
 
            # Allow Apache to write to cache
            chmod g+w $htdocs/wp-content/cache
            echo "> Added write permissions to htdocs/wp-content/cache"
        fi
 
        # If w3tc-config directory does not exist
        if [ ! -d "$htdocs/wp-content/w3tc-config" ]; then
            echo "> Determined htdocs/wp-content/w3tc-config does not exist"
 
            # Create w3tc-config directory for configuration files
            mkdir $htdocs/wp-content/w3tc-config
            echo "> Created w3tc-config in htdocs/wp-content"
 
            # Allow Apache to write to w3tc-config
            chmod g+w $htdocs/wp-content/w3tc-config
            echo "> Added write permissions to htdocs/wp-content/w3tc-config"
        fi
    fi
fi
# If website is WordPress powered
if [ -d "$htdocs/wordpress" ]; then
	echo "> Determined website runs WordPress"

	# If uploads directory does not exist
	if [ ! -d "$htdocs/wp-content/uploads" ]; then
		echo '> Determined htdocs/wp-content/uploads does not exist'

		# Create uploads directory
		mkdir $htdocs/wp-content/uploads
		echo '> Created uploads in htdocs/wp-content'

		# Give Apache permissions to write to uploads
		chmod g+w $htdocs/wp-content/uploads
		echo '> Added write permissions to htdocs/wp-content/uploads'
	fi

	# If w3-total-cache directory exists
	if [ -d "$htdocs/wp-content/plugins/w3-total-cache" ]; then
		echo "> Determined WordPress has W3 Total Cache installed"

		# If cache directory does not exist
		if [ ! -d "$htdocs/wp-content/cache" ]; then
			echo "> Determined htdocs/wp-content/cache does not exist"

			# Create cache directory for cache files
			mkdir $htdocs/wp-content/cache
			echo "> Created cache in htdocs/wp-content"

			# Allow Apache to write to cache
			chmod g+w $htdocs/wp-content/cache
			echo "> Added write permissions to htdocs/wp-content/cache"
		fi

		# If w3tc-config directory does not exist
		if [ ! -d "$htdocs/wp-content/w3tc-config" ]; then
			echo "> Determined htdocs/wp-content/w3tc-config does not exist"

			# Create w3tc-config directory for configuration files
			mkdir $htdocs/wp-content/w3tc-config
			echo "> Created w3tc-config in htdocs/wp-content"

			# Allow Apache to write to w3tc-config
			chmod g+w $htdocs/wp-content/w3tc-config
			echo "> Added write permissions to htdocs/wp-content/w3tc-config"
		fi
	fi
fi
  1. Ensure that the project we deployed is powered by WordPress by checking that there’s a wordpress directory containing the WordPress submodule.
  2. Create the uploads directory if it doesn’t already exist, and give the web server write permissions to it.
  3. Create additional directories required by installed plugins, as determined by the plugin directory name in the plugin directory.

Conclusion

I’ve used this setup for a while now and it has worked quite well. You can view the complete script in my post-receive Gist over at GitHub. If you have any questions, problems or improvements, share them in the comments below.

9 thoughts on “Git post-receive hook to deploy WordPress and plugins as submodules

  1. Brendon

    Hi Ryan, great writeup, great technique!
    I’m scratching my head wondering where you set the $FH_ENVIRONMENT variable.

    Reply
    1. Ryan Sechrest Post author

      Hi Brendon,

      1. You can actually call this whatever you like. Generally something like APP_ENVIRONMENT, since FH doesn’t really mean a whole lot to you 😉

      2. You set this within an Apache configuration file. For example, you can place it in /etc/httpd/conf/httpd.conf to make this variable available to all virtual hosts, or you can simply place it within a specific virtual host file. All you need to do is put something like this on its own line: APP_ENVIRONMENT DEVELOPMENT and change out the environment name on each server. That variable and its value will then be available as a global in PHP.

      Reply
  2. Thiago

    Thank you for this brilliant guide! I was just wondering, where do I modify ‘APP_ENVIRONMENT DEVELOPMENT’ for each server? I have added APP_ENVIRONMENT DEVELOPMENT to ‘app_environment.conf’ and included it into httpd.conf as you suggested, but as a newbie at server management i have no idea how to modify the individual server 🙂
    Thanks for any help in advance!

    Reply
    1. Ryan Sechrest Post author

      Hi Thiago,

      Can you clarify what you mean by “modify the individual server”? Once you’ve added the environment variable to an Apache configuration file, which it sounds like you have, and you reload Apache (service httpd reload), you should be good to go.

      Reply
      1. Thiago

        Hi Ryan, thank you for such a prompt reply!
        I think I got a little confused, I really didn’t get much sleep because I have been trying so hard to get this working! I currently own a VPS with mediatemple and am hosting a few client’s sites on the server.

        I was a little confused about setting APP_ENVIRONMENT DEVELOPMENT server-wide. So all we are doing here is making this variable available am I right?

        So far I have been going through your guides and have been able to create a ‘web’ group with users ‘git’ and ‘apache’. I give the group access to /var/repo/ in which I create bare git repos. In them I have create post-receive hooks which set the work tree to /var/www/vhosts/domain.com/httpdocs/ (which is how the server came set up). I can successfully push to my bare repos, which in turn copy the repo contents to the set webroot.

        My next mission is to now set up the post-hook you have laid out here, using WordPress. I currently use Mark Jaquith’s WordPress Skeleton which includes WordPress as a submodule. Unfortunately there seems to be no clear guide on how to get a submodule to work with hooks so I am hoping yours will do the trick!

        PS I am a self-taught web developer, with less than a year experience, and man is it time-consuming and challenging having to learn git, gulp, linux-shell, server-admin, business, client relations etc etc etc! And people think its HTML+CSS…

        Reply
        1. Ryan Sechrest Post author

          Hi Thiago,

          Yes, putting APP_ENVIRONMENT in the Apache configuration file makes it available to the shell script and your PHP application (WordPress). It is used by the shell script to determine what type of server it is (development, staging, production) and then listens for the appropriate branch being pushed in that environment. This value will also then be available in PHP, should you have, for example, different database credentials for different environments. If you only have one environment, you don’t even really need this, because it will default to production and listen for the master branch.

          Glad to hear your making progress and with regard to updating submodules on a push, yes, this post-receive hook will take care of that (both the WordPress submodule and any plugins that are installed as submodules).

          Keep hanging in there– we all started where you are today!

          Reply
  3. Thiago

    Thank you so much for your help so far Ryan, and the encouragement. I can get quite stubborn, I NEED to know how to do this hehe.

    So far I have managed to wrap my head around the Plesk user permissions, I ended up adding plesks user to web, and adding git to plesk’s client group, with permissions to write in httpdocs (webroot). So far I have modified your file and removed the staging variable as I am only going to be using two servers, develop and production (master).

    I could not get the script to do anything yet, but when I execute it myself via bash I get:

    sh-4.1$ /opt/scripts/git/hooks/post-receive.sh dev.domain.com domain.com dev
    
    > Received  branch
    > Determined environment is PRODUCTION

    But that’s all it tells me. I will dig into the logs, after I find them to see what else I can do. So far, if I use a post-receive hook with a simple work tree directive, it works:

    #!/bin/sh
    git --work-tree=/var/www/vhosts/domain.com/dev --git-dir=/var/repo/hooks-test.git checkout -f

    Except for the damn submodules, the wp folder remains empty. I am contemplating using Plesk to manage wordpress and only use git for the actual theme itself. Its a battle between determination and patience now!

    Reply
    1. Ryan Sechrest Post author

      Hi Thiago,

      I’m glad to hear you figured out the permissions issue and happy that you shared that for anyone else who is running into the same issues.

      When you manually run the post-receive.sh shell script, you will only see those two lines, because you’re not actually pushing a branch. So, once the environment has been detected, it checks to see which branch it should work with, but since there isn’t one, there are no other steps to take.

      If you use something like SourceTree to push your code, go to Preferences > General and make sure “Always display full console output” is checked. That will give you more feedback than simply success or fail. If you’re pushing via the command line, you should see everything already.

      Does the script seem to stop at the same place when you actually push a branch via something like git push prd master, where prd is the production server remote?

      Reply

Leave a Reply

Your email address will not be published. Required fields are marked *