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.
Webhooks Git hooks
Forges implement CI via webhooks. When webhooks are configured, the forge will send HTTP POST requests when certain eve…
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.
Webhooks Git hooks
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.
Where we’re going, we don’t need forges
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.
Smol test and deploy
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:
- Create a new copy of the repository, with
HEADpointing at the “new” commit - In that copy, point the updated ref to the “new” commit (i.e. do the update but only in the new copy)
- Run a
maketarget:make test-ciormake install-cd. Any logic for the repository should come from the repository’s Makefile; of course, themaketarget 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
Why? Why not?
Testing on push
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.
Testing remotely
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.
Trusting myself
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.
Try it out!
Let me know if you try these scripts, and if they do or don’t work well for you!
Terms as used
CT Continuous Testing: the practice of running tests automatically during development. Ideally, tests always pass on the main branch, and this is enforced by automation. CI Continuous Integration: the practice of regularly merging in-progress work into the main branch. Doing so makes merge conflicts or behavior conflicts visible sooner rather than later. Martin Fowler suggests a heuristic, “you should never have more tha a day’s work unintegrated”; I’d say “you should merge each portion of work that can be reviewed on its own,” as in my experience building a reasonably independent PR may take more than a day, and may require you to know about the future direction. CD Continuous Deployment: automatically and frequently producing release builds and rolling them out to the production environment. In the limit, every commit on the main branch gets deployed. More often in my professional experience, the release process takes longer than the average commit rate, so releases are frequent but not per-commit. forge A server for hosting source code and providing affiliated services, such as access control, review/merge flows, issue tracking, and automation. I suspect the term comes from SourceForge, an early forge. GitHub is probably the most popular. Gitea and Forgejo are two self-hostable forges; Codeberg is an instance of Forgejo. I now mostly host my projects on Codeberg.
Footnotes
Yes, this is fun for me. Don’t yuck my yum. ↩︎ 1.
That said, this project does have a copy on Codeberg, as an off-site / “harder for me to delete” backup. I’m also using the issue tracker for myself, though I could ~as easily use a document in my journal; maybe someday I’ll take other contributions. ↩︎ 1.
Google Cloud Platform has a free tier that includes a very small (<1 vCPU) VM and 30GB of disk. Note: when creating the VM, select “standard” disk rather than the default “balanced”–“standard” disk is free, “balanced” isn’t.
I don’t have any affiliate relationship with Google– I don’t directly anything if you use the free tier, or decide to go beyond it. But I used to work at Google and still have some Google stock, so I may benefit indirectly if you buy from Google. ↩︎ 1.
I hereby release the test.sh and deploy.sh scripts into the public domain. I provide no guarantee or warranty of quality, usability, or suitability for any purpose. ↩︎
1.
Golang developers: “What, your build and test cycle takes seconds?” I know, Go has optimized for that cycle being short, and it’s great in that respect; I’d consider a post-commit test hook for a mostly-Go repository. ↩︎ 1.
“Use the stash for that!” I don’t necessarily want to remove my work from the working directory; I want to checkpoint a known state, and then continue work. Later I’ll get rid of the checkpoint with a squash or fixup, but I do want it present and described. ↩︎