Static Site Push to Deploy
When I moved my site to Jekyll, I knew that one thing I wanted to setup was a good push-to-deploy workflow. Coming from WordPress, where publishing a post is a single mouse click, I knew that the harder it is to publish a post, the less likely I am to do it. This is how I ended up setting it all up.
The vast majority of the work I do on my site is done locally on my laptop and then pushed to the server when it’s ready. However, there are occasions where I want to try something directly on the live site. It’s a personal site, so I don’t care as much about accidentally breaking something. But what this means is that I have a full checkout of my website source on the server, not just a bare repository as is often discussed in posts about setting up push-to-deploy. However, git really doesn’t like you pushing into the current branch of a non-bare repository, and by default won’t let you do it:
% git push origin main
Total 0 (delta 0), reused 0 (delta 0)
remote: error: refusing to update checked out branch: refs/heads/main
remote: error: By default, updating the current branch in a non-bare repository
remote: error: is denied, because it will make the index and work tree inconsistent
remote: error: with what you pushed, and will require 'git reset --hard' to match
remote: error: the work tree to HEAD.
As the error message indicates, there’s good reason for this, since it will leave the repository in
an inconsistent state. Performing a git reset --hard
will reset the state, but if there were any
local changes in the remote repository, they will be completely lost.
refs/push/main
So instead of pushing to the current branch, I have a dedicated ref on the server named
push/main
that I use just for pushing changes live. In my git client on my laptop, I have the
following config:
[remote "live"]
url = judah:/var/www/willnorris.com
push = refs/heads/main:refs/push/main
This allows me to simply run git push live
to push my changes into push/main
. Then I can use
hooks on the server to merge those changes into the main branch and rebuild the site.
pre-receive hook
First, I have a pre-receive hook that verifies that my working copy on the server is clean. If I
have any changes in the server’s working copy that haven’t been checked in yet, the push immediately
fails. This doesn’t happen often, but when it does I want it to fail quickly since it’s something I
want to fix right then. This is done using the require_clean_work_tree
shell function that ships
with git. My full pre-receive hook is:
#!/bin/bash
source "$(git --exec-path)/git-sh-setup"
export GIT_WORK_TREE=..
require_clean_work_tree "push changes"
Attempting to push when I have uncommitted changes on the server will fail with the message:
% git push live
Total 0 (delta 0), reused 0 (delta 0)
remote: Cannot push changes: Your index contains uncommitted changes.
To judah:/var/www/willnorris.com
! [remote rejected] main -> refs/push/main (pre-receive hook declined)
error: failed to push some refs to 'judah:/var/www/willnorris.com'
post-receive hook
If the pre-receive hook passes, I then have a post-receive hook that merges the changes from
push/main
and runs jekyll to build the site. But what if instead of uncommitted changes, I have
committed changes in the server’s working copy? I don’t want to overwrite those changes, so I pass
the --ff-only
flag to git merge
to ensure it only performs fast-forward merges. If it’s unable
to fast-forward merge, then the post-receive hook fails with a message like:
% git push live
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 311 bytes | 0 bytes/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: fatal: Not possible to fast-forward, aborting.
To judah:/var/www/willnorris.com
bdb2c1a..a2727f4 main -> refs/push/main
In this case I have to manually fix it, often by force pushing, then manually rebasing the changes on the server. Again, this doesn’t happen often, and so is not something I’ve bothered to try and automate.
If the merge succeeds, then the post-receive hook runs jekyll to rebuild the site. My full post-receive hook is:
#!/bin/bash
export GIT_WORK_TREE=..
git merge --ff-only push/main || exit $?
pushd $GIT_WORK_TREE
export JEKYLL_ENV="production"
jekyll build
popd
A couple of lines (like setting JEKYLL_ENV
and calling popd
at the end) are not strictly
necessary, but also don’t harm anything. Assuming all goes well, a successful push looks like:
% git push live
Counting objects: 3, done.
Delta compression using up to 4 threads.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 311 bytes | 0 bytes/s, done.
Total 3 (delta 2), reused 0 (delta 0)
remote: Updating bdb2c1a..a2727f4
remote: Fast-forward
remote: _config.yml | 2 +-
remote: 1 file changed, 1 insertion(+), 1 deletion(-)
remote: /var/www/willnorris.com /var/www/willnorris.com/.git
remote: Configuration file: /var/www/willnorris.com/_config.yml
remote: Source: src
remote: Destination: public
remote: Generating...
remote: done.
remote: /var/www/willnorris.com/.git
To judah:/var/www/willnorris.com
bdb2c1a..a2727f4 main -> refs/push/main
And that’s it… a few lines in .git/config
on my laptop and two small hooks on the server. There
is still some room for improvement; for example, right now the post-receive hook runs regardless of
what ref I push to. This could be updated to only run when I push to push/main
, but as it is,
that’s all I ever do so I’m not too worried about it. This is good enough to give me very easy
push-to-deploy while making sure I don’t overwrite any changes on the server.
Thanks to Junio Hamano for recommending much of the above approach based on some of his own projects.