I use ReadTheDocs for several of my projects. It's fantastic: among other things, it makes it easy to publish the documentation for my latest main
branch at /latest/
and the documentation for my latest release at /stable/
(as well as maintain archived tag URLs for every prior release).
I can then configure the main page of my project's documentation to redirect to /stable/
by default.
I'm using it for the following documentation sites:
And quite a few more.
There's a catch: by default, the only way to update the /stable/
documentation is to ship a new release.
In the past, I've shipped x.x.1
version bumps just to get new documentation published to ReadTheDocs!
This isn't great though, especially now I have some of my packages in Homebrew. Shipping a release of Datasette or sqlite-utils
or LLM means the Homebrew formula has to be updated by someone too, which feels like a waste of time and effort if the only change was to the documentation.
Another challenge is that there are things I want to include in my documentation that aren't actually coupled to releases. Project news is one example, but a better one is plugin listings: when a new plugin is released I'd like to include it in the official documentation, but it's not a code change that justifies pushing a new release.
What I really want is a way to trigger and publish a new build of the /stable/
documentation on ReadTheDocs without having to ship a new release.
After some extensive experimentation (that's an issue thread with 43 comments, all by me) I've found a solution.
The basic shape is this: rather than having ReadTheDocs serve /stable/
from the latest tagged release of my project, I instead maintain a stable
branch in the GitHub repository. It's this branch that becomes the default documentation on my documentation sites.
Then I use GitHub Actions to automate the process of updating that branch. In particular:
stable
branch is entirely updated to reflect the content of that release. Any changes in that branch are discarded.main
branch as well.main
I can include the text !stable-docs
in my commit message. If I do that, GitHub Actions will copy the exact content of any files in the docs/
directory that were modified in that commit and use them to update the stable
branch, then publish that branch with those new changes.For general usage I only have to do two things: continue to ship releases, and occasionally include !stable-docs
in a commit that updates a document which I'd like to be reflected instantly on the documentation site (a typo fix, project news or new plugin for example).
Here's the full .github/workflows/stable-docs.yml
workflow file I built to implement this process:
name: Update Stable Docs
on:
release:
types: [published]
push:
branches:
- main
permissions:
contents: write
jobs:
update_stable_docs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # We need all commits to find docs/ changes
- name: Set up Git user
run: |
git config user.name "Automated"
git config user.email "actions@users.noreply.github.com"
- name: Create stable branch if it does not yet exist
run: |
if ! git ls-remote --heads origin stable | grep stable; then
git checkout -b stable
# If there are any releases, copy docs/ in from most recent
LATEST_RELEASE=$(git tag | sort -Vr | head -n1)
if [ -n "$LATEST_RELEASE" ]; then
rm -rf docs/
git checkout $LATEST_RELEASE -- docs/
fi
git commit -m "Populate docs/ from $LATEST_RELEASE" || echo "No changes"
git push -u origin stable
fi
- name: Handle Release
if: github.event_name == 'release'
run: |
git fetch --all
git checkout stable
git reset --hard ${GITHUB_REF#refs/tags/}
git push origin stable --force
- name: Handle Commit to Main
if: contains(github.event.head_commit.message, '!stable-docs')
run: |
git fetch origin
git checkout -b stable origin/stable
# Get the list of modified files in docs/ from the current commit
FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)
# Check if the list of files is non-empty
if [[ -n "$FILES" ]]; then
# Checkout those files to the stable branch to over-write with their contents
for FILE in $FILES; do
git checkout ${{ github.sha }} -- $FILE
done
git add docs/
git commit -m "Doc changes from ${{ github.sha }}"
git push origin stable
else
echo "No changes to docs/ in this commit."
exit 0
fi
There are three interesting step blocks here.
The first block is there purely to create that stable
branch if it does not exist yet. This means I can drop the above workflow into a new project without having to do any additional setup against the repo:
- name: Create stable branch if it does not yet exist
run: |
if ! git ls-remote --heads origin stable | grep stable; then
git checkout -b stable
# If there are any releases, copy docs/ in from most recent
LATEST_RELEASE=$(git tag | sort -Vr | head -n1)
if [ -n "$LATEST_RELEASE" ]; then
rm -rf docs/
git checkout $LATEST_RELEASE -- docs/
fi
git commit -m "Populate docs/ from $LATEST_RELEASE" || echo "No changes"
git push -u origin stable
fi
There's another trick in there though. When I add this workflow to a new repository I'm fine for that stable
branch to start off directly reflecting main
.
But... if I add it to a repository that already has releases, I need the stable
branch to start out reflecting the documentation in that most recent release.
That's what the LATEST_RELEASE
variable is for. git tag
outputs the list of tags as a newline-separated list. Piping them through sort
and head -n1
gives the highest release tag:
git tag | sort -Vr | head -n1
sort -Vr
causes sort
to sort the tags using version sort in reverse. Here's how that -V
option is described:
It behaves like a standard sort, except that each sequence of decimal digits is treated numerically as an index/version number.
Getting the commit that creates the new branch to work was surprisingly tricky!
The problem is that GitHub Actions has a rule that a workflow is not allowed to modify its own YAML configuration and then push those changes back up to GitHub.
My first version of this worked by creating the new stable
branch from the most recent tagged release. But... this carries the risk of that tagged version including a change to the workflow YAML, which results in an error.
Eventually I realized that I only care about the contents of that docs/
directory, so instead of creating the branch from the release tag
I could instead create the branch from main
and then copy in the docs/
directory from that tag.
This avoids any chance of a file in .github/workflows
being updated in a way that would break the Actions run.
The next block is the block that fires only when I publish a new release to GitHub:
- name: Handle Release
if: github.event_name == 'release'
run: |
git fetch --all
git checkout stable
git reset --hard ${GITHUB_REF#refs/tags/}
git push origin stable --force
It does a hard reset to reset the content of the stable
branch to the exact content of the tag that is being released, then does a force push to update the remote branch.
For some reason this doesn't seem to trigger that error I head earlier about the YAML workflow being updated. I'm not sure why - maybe it's because resetting to a tag is seen as a "safe" operation somehow?
ReadTheDocs watches for changes pushed to the stable
branch, so this is enough to trigger a new build of the /stable/
documentation.
The last piece is the most complex. It handles that !stable-docs
tag and, when it sees it, copies any files in docs/
that were modified in the commit over to the stable
branch:
- name: Handle Commit to Main
if: contains(github.event.head_commit.message, '!stable-docs')
run: |
git fetch origin
git checkout -b stable origin/stable
# Get the list of modified files in docs/ from the current commit
FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)
# Check if the list of files is non-empty
if [[ -n "$FILES" ]]; then
# Checkout those files to the stable branch to over-write with their contents
for FILE in $FILES; do
git checkout ${{ github.sha }} -- $FILE
done
git add docs/
git commit -m "Doc changes from ${{ github.sha }}"
git push origin stable
else
echo "No changes to docs/ in this commit."
exit 0
fi
I got GPT-4 assistance with all of these (the issue thread links to some of my prompts) but this was the one that took the most iteration. Let's break it down:
The first question we need to answer is what files in docs/
were edited by the current commit:
FILES=$(git diff-tree --no-commit-id --name-only -r ${{ github.sha }} -- docs/)
If you run this command in a repo, you'll see a list of the names of the files that were modified in the most recent commit:
git diff-tree --no-commit-id --name-only -r main
Output is something like this:
docs/cli-reference.rst
docs/cli.rst
sqlite_utils/cli.py
tests/test_cli.py
If you add -- docs/
at the end it will filter that down to just the files that match that path:
git diff-tree --no-commit-id --name-only -r main -- docs/
docs/cli-reference.rst
docs/cli.rst
The workflow assigns that to the FILES
variable, then checks if it is empty:
if [[ -n "$FILES" ]]; then
If it's not empty, it loops through it and uses git checkout
to checkout the exact copy of that file from the current commit, which will over-write the file in the current working directory:
for FILE in $FILES; do
git checkout ${{ github.sha }} -- $FILE
done
Finally, it adds those files, commits them and pushes them to the stable
branch:
git add docs/
git commit -m "Doc changes from ${{ github.sha }}"
git push origin stable
Here's an example commit that was created by this workflow.
There's one last step to putting this into action: reconfiguring ReadTheDocs to both build this new stable
branch and to treat it as the default version for the project.
Here's the sequence of steps.
stable
branch and cause it to be hosted at /stable/
on ReadTheDocs.Here's a GIF illustrating those steps:
And that should be it! With this workflow in place and ReadTheDocs configured the following things should now be possible:
/stable/
documentation will be updated to the docs for that release.stable
branch and they will be quickly reflected in that /stable/
documentation. Apply them to main
too though or they'll be lost the next time you publish a release./stable/
documentation, add !stable-docs
to the commit message. The workflow will copy the full page content into the stable
branch and trigger a new build.I'm using this pattern for my simonw/llm repository now, and I expect to upgrade other repositories to it soon.
Created 2023-08-20T22:05:41-07:00, updated 2023-08-22T06:40:08-07:00 · History · Edit