I started working on a small-as-a-goal CT/CD system, just for fun.1 Of course, while it’s a work-in-progress, it doesn’t actually do the “continuous test” or “continuous release”; so what do I do?
I wrote a couple shell scripts, test.sh and deploy.sh, that act as a bare-bones test-and-deploy system. These assume the repository operator trusts everyone who can push, so be careful in your use! For self-hosters and personal projects, I hope this article helps you automate a little bit of your process.
Forges implement CI via webhooks. When webhooks are configured, the forge will send HTTP POST requests when certain events occur; for instance, if a branch is updated, or someone makes a comment. The request includes a description of the event: the repository, commit, branch, user, etc. as applicable.
The webhook receiver can do whatever it wants with this information. Often, it kicks off a test run, and the webhook receiver eventually posts the test results back to the forge.
Look at the term again: “web” hooks imply the existence of “non-web” hooks! And yes, webhooks are based on Git hooks. By putting programs (or links) in a repository’s .git/hooks/ directory, you tell Git to run the programs when certain events occur.
Note that “repository” here means “a particular copy of the repository on disk”, not “the abstract set of data that gets shared between machines”. .git files are not themselves subject to source control: if you set up hooks, those are local to that copy of the repository.
I have various projects hosted on GitHub and Codeberg, but for a single-committer personal project like this, the forges aren’t super valuable. I trust the committers, so I don’t really need access control; I’m not taking contributions, so I don’t need pull requests; I don’t need to coordinate with others, so I don’t really need an issue tracker.2
All you need is SSH to self host Git! You can “just” set up a repository on another machine. It doesn’t even have to be a powerful/expensive machine! 3 For this project, I have a bare repository on a VM; the VM also hosts the “test” deployment of the CT/CD server.
I have this repository configured with an update Git hook. The update hook runs during the update of a ref (tag or branch), after all data has been received, but before the ref is actually updated. For instance, if I push commit a1b2c3feed to the main branch, the hook will run when commit a1b2c3feed is available, but when main still points to the older commit. This way, if the update hook fails, the ref is unchanged: a commit that fails tests will not update the main branch.
What’s in the update hook? It’s a symlink to one of two scripts, test.sh or deploy.sh, depending on whether you want testing or testing+deployment.
They mostly work the same way:
HEAD pointing at the “new” commitmake target: make test-ci or make install-cd. Any logic for the repository should come from the repository’s Makefile; of course, the make target can invoke whatever other tools are needed.The deploy.sh script has a step before these: it invokes test.sh, so it can be used as a drop-in replacement that adds deployment. Also, deploy.sh will only do the “deploy” part if it’s invoked for the main branch; for any other ref, it only runs tests.
That’s it! No YAML, Groovy, or JSON needed. You can read the scripts here: test.sh and deploy.sh. Feel free to use them, though any issues are on your own head.4
Some folks use client-side Git hooks, like pre-commit, to run tests. That doesn’t work well for me for a few reasons.
First, I’m mostly writing Rust nowadays, where the build times are relatively long (seconds). And I often shuffle stacks of commits around with git-branchless and lazygit. Running a test cycle on every commit update would slow down a rebase, for little to no benefit.5
Second, I don’t want to require myself to have every transient commit be test-clean. Sometimes I have a work-in-progress that I want to checkpoint; I don’t want to stop myself from “saving” because the tests are in an intermediate state.6 When operating in test-driven development mode, I’ll want to commit failing tests before starting work on the feature.
In short, I make a lot of local commits which are never seen elsewhere. The point at which I care about enforcing tests is integration: when I consider the unit of work “complete”, or (in a collaborative project) ready to merge. In the workflows I’m using, that corresponds with a push, not a commit.
I like to test on a different machine than my dev environment to avoid “it works on my machine”. I often forget to git add a file, or to mark that I installed a dependency. Running the tests in a separate context gives me a second start at reproducing the results.
There are ways to get “a separate context” locally: a separate checkout (as the test script does) and a Docker container, for instance. I can imagine an alternative to my setup that uses e.g. the pre-push hook to do a similar flow locally. Let me know if you try that out!
Running the testing on a server also gives me an extra physical copy, in case my development machine dies. Since that server is the same one used for the deployment, bam, continuous deployment.
Note that this setup runs the tests on the Git host… without any isolation, without any resource limits, etc. etc.
That’s usually a bad idea! I wouldn’t use this if I were accepting pushes from strangers on the Internet, or even in a mostly-trusted professional context; that’s where the branch protection / access control rules of forge software adds value. Or where “a local hook” makes more sense.
In my particular case, I’m pushing to a VM I own, logging in as myself, and I’ve written all the code in the repository. It won’t do anything I wouldn’t couldn’t do by developing on the VM itself. Having the scripts and hooks gets me that little bit of extra confidence that I can pick up the project later and it will Mostly Work.
Let me know if you try these scripts, and if they do or don’t work well for you!