exanubes

Using a Git Repository as a Release Artifact

Recently I’ve encountered an issue of trying to publish a neovim plugin from my monorepo-esque project repository. There’s a convention for neovim plugins that they ought to be located in repositories with a .nvim suffix and I wanted to respect it, however, I did not want to have to work on two separate repositories. So I figured, a github repository could be my build/release artifact. Similar to how a golang binary can be produced as an artifact, code can be sent to a server in the cloud or published to github pages, I decided to publish to a github repo as part of my release process.

Subtree-Based Publishing

For my first implementation I opted for using git subtree to publish the Neovim plugin from the subdirectory in which it lived in my source repository. My goals where:

  • keeping the plugin repository minimal
  • avoiding code duplication i.e., having to copy/paste or manually track pushes/releases for two different remotes
  • injecting release version into lua code
  • establishing a clear relationship between the source repository and the artifact repository
  • automating publishing using Github Actions

The subtree was supposed to be pushed on every release, and the same release tag was created in the artifact repository.

Issues

However, as I learned how git subtree works, I started having second thoughts. My plugin has a strict dependency on my RPC server binary which holds the domain logic and I really wanted to make the plugin setup seemless so I needed a way to make the plugin download the binary automatically from Github Realeases. To avoid version drift between the two I’m comparing the version of the plugin, with the version of the installed plugin and download the matching version if it’s missing or inconsistent.

All this to say that I had to generate a commit that updates the .lua file that holds these values, however, I wasn’t happy about having this generated commit in the source repository’s commit history every time I release something. This would mean that every time I push a tag that triggers a deployment it’s actually the commit after the tag that defines the state of the released software. Not only that, I’d have two identical commit history trees in two separate repositories because there’s no way to “decouple” the history trees using git subtree, which makes sense looking at the name of the command.

In my mind, the published artifact shouldn’t really rely on the history, it should be just a snapshot of the code at the time of the release process. With subtree it seemed much more complicated than that so I moved on from this idea.

Simple is best

After the subtree experiment, I decided to do this as simple as possible. So, I’ve created the repository, and created a workflow that just checks both the source and the artifact repositories. Overwrote the file for versions, created the commit, tagged it and I was good to go… after a few trials and errors.

Permissions

Because I was trying to access and change a different repository than the workflow runs from, I needed to create a Personal Access Token with a read/write access for content in the artifact repository. Then I added the key to the source repository secrets and passed it to actions/checkout

- name: Checkout plugin repository
  uses: actions/checkout@v4
  with:
    repository: exanubes/typedef.nvim
    token: ${{ secrets.PLUGIN_REPO_PAT }}
    path: artifact
Go to your repository > settings > secrets and variables > actions and create a repository secret
A deploy key will not work in this case

Repository content

Now because I have just created the repository, it was completely empty - no commits. This ‘caused issues in the checkout job because it couldn’t attach HEAD to anything or something like that. Easy fix - add an empty commit to the artifact repository.

git commit -m "INITIAL COMMIT" --allow-empty

Git user

To avoid having commits done in my name, I’ve configured git inside the workflow with the github bot user. This explicitely signals that the artifact repo is read-only, the only way to write to it is through the CI which is a nice touch in my opinion.

git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"

Other than that I faced no real issues with this approach compared to when I tried doing it with subtree. It’s pretty straightforward using the few git commands we all use every day.

See the full workflow on github

Rules

I’ve set a few rules for myself just to keep this safe and maintainable, some of them are automated in CI and some are just my “best practices”:

  • Artifact repository is read-only
    • The only way to update it is through CI
    • All commits are authored by automation
  • The source repository is the only source of truth
    • All behavior, structure, and decisions live in the source repository
    • The artifact repository has no independent logic
    • The only intentional difference is a generated .lua file used for version tracking
  • Release tags must match across repositories
  • No shared git history between the two repositories
    • Repositories should be completely separate
    • Commits that are part of creating the release never leak into the source repository
    • Source repository commits never leak into the artifact repository
  • Each release fully replaces the previous artifact
    • Same as with binaries, a completely new artifact is created on a clean canvas
    • It’s safe to delete all the code, move to a new repo and the next release will recreate everything from scratch
  • All updates go through the source repository
    • Artifact repository should be treated like any other deployment target
    • The same way we wouldn’t just upload a binary to a server directly from our local dev environment
  • All artifacts are always released together and tagged with the same version even if nothing changed for them
    • This avoids version drift between artifacts

Summary

So, after all is said and done, I have two separate repos that are pretty much decoupled. Even if I commit something to the artifact repo it shouldn’t be a problem as it’ll get overwritten by the next release. There shouldn’t be any conflicts either because the overwrite is “forced” i.e., the workflow deletes all the files checked out from the artifact repository and copies over the files from the source repository. So, I can focus on maintaining the one repository while also adhering to the convention when creating a neovim plugin. It was also kind of fun to do something different as I’ve never even heard of using a repository as a deployment target.