Multi-version Doxygen documentation with GitHub Pages
Table of contents
- Introduction
- Problems with mono-version documentation
- Welcome multi-version documentation
- Prerequisites
- Overall design
- Version switch mechanics
- Automation
- Upgrading from a mono to multi-version documentation
- Wrap-up
Introduction
In this article I’ll show how to host a multi-version Doxygen documentation on GitHub Pages and automate its generation using GitHub Actions.
Problems with mono-version documentation
I have a small hobby project that generates documentation using Doxygen and hosts it on GitHub Pages. Nothing special but as the project begins to evolve, I realized that having a single documentation is not nice for multiple reasons:
- it requires special notes like “available/deprecated since version N” around API.
- it requires similar notes for non-API things, like describing overall design, recommended practices, examples.
- the above notes are not generally useful because changes are usually driven by users who are supposed to migrate to the latest version.
Basically, over the time, documentation gets polluted with those notes to cover all the existing versions. Of course, another approach is just to remove older documentation altogether but it’s not friendly to users that for some reasons can’t migrate to the latest version immediately.
Welcome multi-version documentation
All of the above problems can be solved when a project has dedicated documentation for each release. Pretty every large project like Boost or Python uses this approach. Unfortunately, Doxygen doesn’t support this out of the box. It generates a completely standalone set of HTML pages for a given version but has no functionality to combine multiple such sets and allow the user to switch between them.
I’ve found no existing tutorial to achieve this but ChatGPT suggested an approach and guided me through most of its steps. Note that I’m not a front-end nor a DevOps expert so there’s a chance that there’s a space for further improvement but, overall, it should be a solid start for anyone with a similar goal.
Prerequisites
This tutorial uses CMake 3.29.3 and Doxygen 1.10. It also assumes
that project version is specified using CMake project()
command and available
via ${PROJECT_VERSION}
variable.
The very basic CMake + Doxygen setup looks like this:
find_package(Doxygen REQUIRED)
# Doxygen options in the form of `DOXYGEN_<OPTION_NAME>`...
doxygen_add_docs(
doc # target name
"${PROJECT_SOURCE_DIR}" # sources to scan for docs
)
All the following changes are made on top of it. It’s not a problem if you’re using another approach, just apply similar settings in a way you prefer.
The final demo repository is here, it contains all the parts described below. Its docs are here.
Overall design
Here’s the directory structure we need:
/ # docs root
1.0.0/ # version-specific docs
2.0.0/
These dirs will be located in a separate branch (e.g. gh-pages
) that contains
nothing but the docs themselves. On each release (or another event), a new
documentation will be generated using Doxygen and pushed to the root of that
branch into a version-specific directory. As was said above,
generated docs know nothing about each other so our two main problems are:
- version switch mechanics
- automated docs generation and population of the docs branch
Version switch mechanics
Adjusting folder names
By default, Doxygen generates docs into html
directory, to have them in a
version-specific directory we can use HTML_OUTPUT
option:
set(DOXYGEN_HTML_OUTPUT "${PROJECT_VERSION}")
It will produce the docs into a path like build_dir/doc/1.0.0
, where doc
is
the Doxygen target name. But there’s a minor problem here. When we build the
doc
target, we don’t know the actual version and hence the path to generated
docs. Without this knowledge it’s pretty hard to automate the process. To solve
it, let’s add another directory into that path using Doxygen
OUTPUT_DIRECTORY
setting:
set(DOXYGEN_OUTPUT_DIRECTORY "docs")
Now, docs are located in build_dir/doc/docs/1.0.0
and the new docs
directory holds nothing else but our version-specific docs. To determine the
generated version, we can simply enumerate directories in docs
, actually,
there will always be a single one. This trick will be used later in the
building documentation section.
Main page
OK, now we can have docs in separate version-specific directories but what
should be the main page? When docs are hosted on a standalone server over which
you have full control, you can try to update the path to index page on each new
release. GitHub Pages is not so flexible, it always looks for index.html
in
the root directory. To make it work, we have to implement HTML redirect:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=2.0.0/index.html">
<title>Redirecting...</title>
</head>
<body>
<p>If you are not redirected automatically, <a href="2.0.0/index.html">click here</a>.</p>
</body>
</html>
The target URL changes with each release so the generation of this file will be automated later. Here’s how the root directory will look like at this stage:
/ # root of our documentation
1.0.0/ # version-specific directories
index.html # version-specific main page generated by Doxygen
...
2.0.0/
index.html
...
index.html # redirects to the latest release docs, e.g. `2.0.0/index.html`
Version selector
Now comes the interesting part, we need that dropdown selector element that
does the actual switch between versions. Doxygen has PROJECT_NUMBER
option,
when set, it nicely displays the project version next to the project name.
doxygen_add_docs
automatically sets it to ${PROJECT_VERSION}
. If you’re
using standalone Doxygen config, be sure to set it by hand.
That static number has to be replaced with a version selector. Doxygen allows
customization of the header HTML part that’s common for all pages. First, we
need to generate the default one:
doxygen -w html header.html footerFile styleSheetFile Doxyfile.doc
Here, Doxyfile.doc
is Doxygen configuration file generated by
doxygen_add_docs
, it can be found in the binary directory of the doc
target
(e.g.
build_dir/doc/Doxyfile.doc
). If you’re using standalone configuration
file, use it here instead of Doxyfile.doc
.
From 3 files generated by this command, we need only header.html
that we are
about to customize. Opening it, we can see where that version text is located:
<span id="projectnumber"> $projectnumber</span>
During docs generation, Doxygen replaces that $projecnumber
with
PROJECT_NUMBER
value but we don’t need that. Instead, we cut it down to just:
<span id="projectnumber"/>
Then, we add a version_selector_handler.js
script that does 3 things:
- injects
<select>
element fromversion_selector.html
inside theprojectnumber
span - implements redirect when different version is selected
- maintains proper active value of the
<select>
element
Here’s the script:
// version_selector_handler.js
$(function () {
var repoName = window.location.pathname.split('/')[1];
$.get('/' + repoName + '/version_selector.html', function (data) {
// Inject version selector HTML into the page
$('#projectnumber').html(data);
// Event listener to handle version selection
document.getElementById('versionSelector').addEventListener('change', function () {
var selectedVersion = this.value;
window.location.href = '/' + repoName + '/' + selectedVersion + '/index.html';
});
// Set the selected option based on the current version
var currentVersion = window.location.pathname.split('/')[2];
$('#versionSelector').val(currentVersion);
});
});
Note that its URL is in the form of /<repo_name>/version_selector.html
.
That’s because when project is published on GitHub Pages, all its URLs will have
<repo_name>
prefix. It’s not needed if docs are hosted on a fully standalone
domain.
This script has to be injected at the bottom of the <head>
section of the
header.html
:
<!-- header.html -->
<head>
<!-- other content... -->
<script type="text/javascript" src="$relpath^version_selector_handler.js"></script>
</head>
The actual <select>
element is located in /version_selector.html
file and
will be populated automatically. Here’s how it can look like:
<!-- version_selector.html -->
<select id="versionSelector">
<option value="2.0.0">2.0.0</option>
<option value="1.0.0">1.0.0</option>
</select>
Here’s the full structure of our documentation directory:
/ # root of our documentation
1.0.0/ # version-specific directories
version_selector_handler.js # loads `/version_selector.html`
index.html # version-specific main page generated by Doxygen
...
2.0.0/
version_selector_handler.js
index.html
...
index.html # redirects to the latest release, e.g. `2.0.0/index.html`
version_selector.html # holds the actual version list
Note that while every generated documentation has its own
version_selector_handler.js
copy, there’s only one instance of
version_selector.html
. There’s no need to regenerate existing docs to
introduce a new version, only version_selector.html
has to be updated.
We need to adjust Doxygen settings to bring the above changes together:
# specify custom header path
set(DOXYGEN_HTML_HEADER "header.html")
# extra files that will be copied alongside generated HTML files
set(DOXYGEN_HTML_EXTRA_FILES "version_selector_handler.js")
And that’s it, now each HTML page has that <select>
element with version list
which is updated automatically and user can switch between versions as they
want.
Automation
The next major step is automation of the above steps using GitHub Actions. The following assumes you know at least basics of how Actions and Workflows work.
Actions
We need 3 basic building blocks to achieve our goals:
- build the docs
- update
version_selector.html
that contains version list - update
/index.html
HTML redirect
Using them we can implement different strategies using GitHub Workflows.
I’ve extracted them to corresponding custom actions. Here, I will show only
their core parts to keep things short, you can find full sources in the demo
repository. Note that the actions below are made configurable and can be
reused as is, check out their inputs
section for a list of parameters.
Building documentation
# .github/actions/build-docs/action.yaml
steps:
- name: Install deps
shell: bash
run: |
sudo apt install -y cmake
sudo apt install -y wget
wget -nv https://www.doxygen.nl/files/doxygen-1.10.0.linux.bin.tar.gz
tar -xzf doxygen-1.10.0.linux.bin.tar.gz
echo "$(pwd)/doxygen-1.10.0/bin" >> $GITHUB_PATH
- name: CMake configuration
shell: bash
run: cmake $ -B $
- name: CMake build
shell: bash
run: cmake --build $ --target $
- name: Get docs version
id: get-docs-version
shell: bash
run: |
subdir=$(basename $(find $/$ -mindepth 1 -maxdepth 1 -type d | head -n 1))
echo "version=$subdir" >> $GITHUB_OUTPUT
# deploy to either version-specific or to explicitly provided directory
It just installs CMake and Doxygen and builds the target that is responsible for
invoking Doxygen. The only interesting part here is the last step, it retrieves
the version of the generated documentation by simply enumerating directories in
the Doxygen OUTPUT_DIRECTORY
.
Updating version_selector.html
version_selector.html
should contain all the versions we have published on our
docs branch (e.g. gh-pages
). This is achieved by enumerating
directories and then generating HTML file from that list:
# .github/actions/update-version-selector/action.yaml
steps:
- name: Discover versions
id: discover-versions
shell: bash
run: |
git fetch origin $
dirs=$(git ls-tree --name-only -d origin/$ | sort -rV)
echo "counter=$(echo "$dirs" | wc -l | xargs)" >> $GITHUB_OUTPUT
mkdir $
# Create HTML
echo '<select id="$">' > $/$
for dir in $dirs; do
if [[ "$(basename "$dir")" != .* ]]; then
version=$(basename "$dir")
echo " <option value=\"$version\">$version</option>" >> $/$
fi
done
echo '</select>' >> $/$
# deploy step...
Updating redirect page
This one is trivial, just take the target url from the parameter and generate the standard HTML redirect:
# .github/actions/update-redirect-page/action.yaml
steps:
- name: Generate redirect HTML
shell: bash
run: |
mkdir $
cat << EOF > $/$
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="0; url=$">
<title>Redirecting...</title>
</head>
<body>
<p>If you are not redirected automatically, <a href="$">click here</a>.</p>
</body>
</html>
EOF
# deploy step...
Workflows
The above three actions are enough to implement almost any strategy for docs generation. Let’s see a couple of common ones.
Generating git-main
docs
It’s useful to have not only release-specific docs but the ones corresponding
to the latest, not yet released version of a project. This can be achieved by
generating docs from the main
branch whenever new commits are pushed into it.
We’ll give such docs a git-main
“version”:
# .github/workflows/create-git-main-docs.yml
on:
push:
branches:
- main
jobs:
create-git-main-docs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Build docs
id: build-docs
uses: ./.github/actions/build-docs
with:
cmake_target: 'doc'
docs_dir: 'doc/docs'
destination_dir: git-main
github_token: $
- name: Update version selector
id: update-version-selector
uses: ./.github/actions/update-version-selector
with:
github_token: $
- name: Create redirect page if there are no releases
if: $
uses: ./.github/actions/update-redirect-page
with:
github_token: $
target_url: git-main/index.html
The first two steps are self-explanatory, the only thing to notice is
destination_dir: git-main
argument to build-docs
action. It forces
build-docs
to deploy documentation to the git-main
directory, not to a
version-specific one because those are reserved for releases. The last step is
required to create redirect page when your documentation branch has no
release-specific docs yet. It’s useful when you begin to play with this stuff to
test how everything works without making new releases.
Generating release docs
Generating release-specific docs is the main goal of this tutorial and its
workflow is even simpler than the above. This time destination_dir
is not set
so the docs are published into a version-specific directory, e.g. 1.0.0
:
on:
release:
types: [released]
jobs:
create-release-docs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Build docs
id: build-docs
uses: ./.github/actions/build-docs
with:
cmake_target: 'doc'
docs_dir: 'doc/docs'
github_token: $
- name: Update redirect HTML
uses: ./.github/actions/update-redirect-page
with:
github_token: $
target_url: $/index.html
- name: Update version selector
uses: ./.github/actions/update-version-selector
with:
github_token: $
Generating PR docs
The above two workflows are a good basis to generate docs for a project. As an
example of something “extra”, let’s generate docs from a pull request. It can be
useful for projects with many contributors to check that docs for a new feature
are correct. Generating them from every PR makes no sense so we need some
condition here. GitHub has different ways to control when to run such a
workflow, I’ve chosen the simplest one, to run it when PR is labeled as
documentation
. It’s very similar to git-main
workflow but now
destination_dir
is set to
PR-$
and redirect page is never touched:
# .github/workflows/create-pr-docs.yml
on:
pull_request:
types: [labeled, synchronize]
branches:
- main
jobs:
create-pr-docs:
if: $
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
ref: $
- name: Build docs
id: build-docs
uses: ./.github/actions/build-docs
with:
cmake_target: 'doc'
docs_dir: 'doc/docs'
destination_dir: PR-$
github_token: $
- name: Update version selector
uses: ./.github/actions/update-version-selector
with:
github_token: $
Removing PR docs
Unlike git-main
and release docs which stay there forever, PR docs should be
removed when PR is closed:
# .github/workflows/remove-pr-docs.yml
on:
pull_request:
types: [closed]
branches:
- main
jobs:
remove-pr-docs:
if: $
runs-on: ubuntu-22.04
steps:
- name: Remove PR docs
uses: peaceiris/actions-gh-pages@v4
with:
github_token: $
publish_dir: $
destination_dir: PR-$
- uses: actions/checkout@v4
with:
sparse-checkout: .github
- name: Update version selector
uses: ./.github/actions/update-version-selector
with:
github_token: $
Here, peaceiris/actions-gh-pages
removes destination_dir
before pushing new
files to it and since $
is empty, this effectively
results in removing PR docs.
Upgrading from a mono to multi-version documentation
Switch from a mono to multi-version documentation requires a bit of manual
intervention. git-main
docs will be generated automatically once new
functionality is merged into main
, the same applies to new releases but not
to the previous ones. For my project, I’ve decided to integrate only the latest
available release at the time to the new multi-version docs branch. But that
release generates docs without the new version selector functionality and
wouldn’t work as is. Here’s how I’ve done it:
- pulled release tag locally
- applied Doxygen-specific changes on top of it
- generated its documentation, now it has version selector functionality
- manually pushed it to my
gh-pages
branch into the corresponding version-specific directory - manually added that version to
version_selector.html
- updated
index.html
redirect page
That’s it, now old release docs are fully integrated. Not a lot of work for a single release but if one needs this for more releases, it makes sense to automate the process.
Wrap-up
We’re done, at this point we have a fully automated system that generates and publishes documentation for a project allowing user to switch between the versions. The presented approach is not the only one, many other customizations are possible but it should be a good place to start.