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.
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
[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.
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'
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
--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.