Deploying a MkDocs documentation site with GitHub Actions

In the previous post in this series about building documentation sites with MkDocs, I showed you how to host a site on GitHub Pages. We briefly touched upon GitHub Actions, the integrated build and deployment server available on GitHub. In this post, I'll continue the example and get a real deployment pipeline set up.

Deploying a MkDocs documentation site with GitHub Actions

GitHub Actions is yet another free option from GitHub, which is basically a build server in the cloud. I have blogged about Actions in the past and how it compares to other build servers. If you are interested in more details, check out Building and testing on multiple .NET versions with GitHub Actions.

In the previous post, we saw how GitHub Actions is responsible for picking up any commits on the gh-pages branch and deploying them on Pages. On a production site, you typically want to develop directly on the main branch and have a build server automatically pick up changes in Markdown source files and build the static website directly on the build server.

To have GitHub Actions pick up changes to your documentation site and build and deploy them on GitHub Pages, start by going to your repository on GitHub. Navigate to the Actions tab and click New workflow. There's no pre-made template for MkDocs, so go ahead and click set up a workflow yourself to start from blank. This will generate a new file named main.yml in the .github/workflows folder. I'll paste the end file in the following sample and go through each command line-by-line. Include the following code:

name: build
on:
  push:
    branches:
      - main
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-python@v2
        with:
          python-version: 3.x
      - run: pip install mkdocs
      - run: mkdocs gh-deploy --force --clean --verbose

This is basically all you need to have your documentation site automatically deployed. In the first line, we name the workflow "build". You can put any name in there. In the on part, we tell GitHub Actions to run this workflow every time someone pushes commits on the main branch. Remember that the gh-pages branch will be generated by MkDocs and we never want to either check out this branch locally or modify it manually.

In the jobs section we tell Actions to run the workflow on the latest version of Ubuntu. The workflow consists of a list of steps. The first step checks out the latest code. In the second step, we install the latest version of Python 3. The image ubuntu-latest won't have Python installed so we need to make sure this is present on all executions of this workflow.

In the last two steps, we install MkDocs using pip and run the gh-deploy command to generate and push the site on the gh-pages branch. Both commands correspond to what we ran locally in the previous post, so no magic there. Next time anyone commits and pushes on the main branch, GitHub Actions picks up the torch:

Build server running

That's it. All changes are now automatically made available through MkDocs, GitHub Actions, and Pages. Once the build workflow is complete, the pages-build-deployment workflow takes over and deploys the changes to Pages.

A common practice when developing websites is minifying CSS and JavaScript files. Most MkDocs themes come with minified files, but if you want to develop your own theme, you may want original un-minified files when running locally and minified files when running on GitHub Pages. Another potential optimization is bundling all CSS and JavaScript files in a single file per type. This approach is not as important with the increased number of simultaneous requests available in HTTP2, but I still recommend you to try out both to see what works best.

There are a couple of options when it comes to minifying files. As already mentioned, when running with a fixed theme, chances are that the files are already minified but make sure to verify that before publishing your site to GitHub Pages. If you develop a custom theme or the selected theme comes with un-minified files, you can use mkdocs-minify-plugin by installing it through pip:

pip install mkdocs-minify-plugin

Next, insert the following config in the mkdocs.yml files:

- minify:
    minify_html: true
    minify_js: true
    minify_css: true
    htmlmin_opts:
        remove_comments: true
    js_files:
        - assets/js/main.js
    css_files:
        - assets/css/style.css

This will automatically minify the main.js and style.css files when serving the website and/or publishing the site to GitHub Pages. You will need to locate any references to the un-minified files in the HTML template files and replace them with *.min.js files:

<html lang="en">
  <head>
    <link rel="stylesheet" href="{{ 'assets/css/style.min.css'|url }}">
  </head>
  <body>
    <script src="{{ 'assets/js/main.min.js'|url }}"></script>
  </body>
</html>

(only showing the important lines here)

This will let you develop in main.js and style.css and run on minified code when visiting the site in a browser. A downside of this is that you typically don't want minified files when running on localhost, since this will make it hard to debug JavaScript errors and similar.

There are probably many ways to fix this, but here's a simple solution that we have based our implementation on. Set all of the minification config options to false and add a new production property in the mkdocs.yml file:

plugins:
    - minify:
        minify_html: false
        minify_js: false
        minify_css: false
        htmlmin_opts:
            remove_comments: true
        js_files:
            - assets/js/main.js
        css_files:
            - assets/css/style.css
production: false

Next, modify HTML files with references to the main.js and/or style.css file:

<html lang="en">
  <head>
    {% if config.production %}
    <link rel="stylesheet" href="{{ 'assets/css/style.min.css'|url }}">
    {% else %}
    <link rel="stylesheet" href="{{ 'assets/css/style.css'|url }}">
    {% endif %}
  </head>
  <body>
    {% if config.production %}
    <script src="{{ 'assets/js/main.min.js'|url }}"></script>
    {% else %}
    <script src="{{ 'assets/js/main.js'|url }}"></script>
    {% endif %}
  </body>
</html>

In this example, we use the templating language from jinja to include the minified versions if production equals true and the un-minified versions if not.

The final step is to expand the GitHub Actions workflow to override the minify_* and production properties on build. This can be done by inserting the following build steps before publishing the site to GitHub Actions:

- run: pip install mkdocs-minify-plugin
- run: sed -i 's/production:\ false/production:\ true/' mkdocs.yml
- run: sed -i 's/minify_html:\ false/minify_html:\ true/' mkdocs.yml
- run: sed -i 's/minify_js:\ false/minify_js:\ true/' mkdocs.yml
- run: sed -i 's/minify_css:\ false/minify_css:\ true/' mkdocs.yml

When the build is running on GitHub Actions we now get mkdocs-minify-plugin installed and all of the properties needed for running in production get updated.

mkdocs-minify-plugin should be sufficient to cover most needs. In case you need bundling or require special CSS that's not supported by the CSS minifier used by mkdocs-minify-plugin you can pick a different approach: gulp. A quick disclaimer before digging into the config. There are alternatives to gulp and you may prefer something else. This section is meant as an example of using an alternative to mkdocs-minify-plugin but which alternative you prefer, is entirely your call.

Since this approach is an alternative to mkdocs-minify-plugin you will need to remove that plugin and its configuration if you added it as part of the previous section. We still need the production property, so keep that in mkdocs.yml. Start by adding a new file named gulpfile.js to the root of your site:

"use strict";

var gulp = require("gulp"),
    concat = require("gulp-concat"),
    cssmin = require("gulp-cssmin"),
    uglify = require("gulp-terser"),
    merge = require("merge-stream"),
    bundleconfig = require("./bundleconfig.json");
    
    var regex = {
        css: /\.css$/,
        js: /\.js$/
    };

    function getBundles(regexPattern) {
        return bundleconfig.filter(function (bundle) {
            return regexPattern.test(bundle.outputFileName);
        });
    }

    gulp.task("min:js", function () {
        var tasks = getBundles(regex.js).map(function (bundle) {
            return gulp.src(bundle.inputFiles, { base: "." })
                .pipe(concat(bundle.outputFileName))
                .pipe(uglify())
                .pipe(gulp.dest("."));
        });
        return merge(tasks);
    });
    
    gulp.task("min:css", function () {
        var tasks = getBundles(regex.css).map(function (bundle) {
            return gulp.src(bundle.inputFiles, { base: "." })
                .pipe(concat(bundle.outputFileName))
                .pipe(cssmin())
                .pipe(gulp.dest("."));
        });
        return merge(tasks);
    });

    gulp.task("min", gulp.series(["min:js", "min:css"]));

The gulp config will provide a task named min to bundle and minify the CSS and JavaScript. Next, add a new file named bundleconfig.json:

[
  {
    "outputFileName": "mytheme/assets/css/style.min.css",
    "inputFiles": [
      "mytheme/assets/css/bootstrap.css",
      "mytheme/assets/css/style.css"
    ]
  },
  {
    "outputFileName": "mytheme/assets/js/main.min.js",
    "inputFiles": [
      "mytheme/assets/js/bootstrap.js",
      "mytheme/assets/js/main.js"
    ]
  }
]

If you are not familiar with .NET, the file probably doesn't look familiar. I simply picked this file to write my bundle config in a file I already use from .NET projects, but it could be anything that specifies the input and output files (including hardcoded values in the gulp file).

The final file to be added is a package.json file that will list the required npm packages to run gulp. Create the file by running:

npm init
Simple hit Enter on all steps to accept the default values. Then all of the npm packages needed for gulp:
npm install gulp --save-dev
npm install gulp-concat --save-dev
npm install gulp-cssmin --save-dev
npm install gulp-terser --save-dev
npm install merge-stream --save-dev

This will install the required npm packages locally and install them to the devDependencies property in package.json.

The final step is to extend the main.yml workflow file to have gulp running on GitHub Actions. This can be done by including the following build steps before running the gh-deploy command:

- uses: actions/setup-node@v1
  with:
    node-version: 12.16.0
- run: npm install
- run: gulp min

The script will install Node.js, install the npm packages listed in the package.json file and run the gulp command min that we added to the gulpfile.js file.

That was what I have decided to show in regards to MkDocs and how we use it to build elmah.io's documentation site. If you have any suggestions for future posts, let us know through the support widget. For general questions about MkDocs, please use MkDocs' issue tracker or Stack Overflow.

elmah.io: Error logging and Uptime Monitoring for your web apps

This blog post is brought to you by elmah.io. elmah.io is error logging, uptime monitoring, deployment tracking, and service heartbeats for your .NET and JavaScript applications. Stop relying on your users to notify you when something is wrong or dig through hundreds of megabytes of log files spread across servers. With elmah.io, we store all of your log messages, notify you through popular channels like email, Slack, and Microsoft Teams, and help you fix errors fast.

See how we can help you monitor your website for crashes Monitor your website