Setup GitLab Review Apps with Eleventy

GitLab Pages provide an easy means of deploying a site hosted on GitLab, but GitLab does not provide support for creating Review Apps for a Pages site. This post outlines a reusable technique to work around that and setup Review Apps with Eleventy to enable creation of a unique, browsable instance of a site with the changes in a merge request.

Overview of GitLab review apps #

GitLab Review Apps allow the creation of temporary environments deploying the code changes in a merge request. Once configured, a new environment is created for each merge request, allowing review of a running version of the application. A link to that application is provided in the merge request widget to simplify access. That temporary environment is destroyed after a specified time period, or when the merge request is merged.

Currently, GitLab only allows a single Pages site and doesn't support the deployment of Review Apps for Pages, although there is an open issue to enable that capability in the future. Until that is implemented, this technique allows adding that capability for Eleventy sites.

GitLab pages setup #

To start with a common foundation, a job to deploy the site to GitLab Pages is configured. The requirements to deploy a Pages site are a job in the pipeline named pages that saves the site as artifacts in the public/ directory. This job builds on that with a few additional capabilities.

  • The jobs uses the node:lts-alpine container image, a lightweight image capable of running Node projects.
  • The rules are set to only deploy the Pages site for pipelines on the default branch.
  • A variable BUILD_TYPE is added to denote this is a pages build. This is used in the eleventy configuration to control aspects of the Eleventy build.
  • The job sets up a production environment, which enables the built-in GitLab features for tracking Pages deployments. The url should be set to the URL of the final site.
  • The script installs all dependencies and runs the npm build script, assuming this builds the Eleventy site (for example npx @11ty/eleventy). These steps are included here to have a complete job definition, but depending on the pipeline those actions could be done in another job and its artifacts used in the pages job.

The complete pages job is shown below.

pages:
  image: node:lts-alpine
  stage: deploy
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  variables:
    BUILD_TYPE: pages
  environment:
    name: production
    deployment_tier: production
    url: https://<site_url>/
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - public

Review apps setup #

There are several steps to configure Review Apps, including enabling Review Apps for the project, setting up the CI job, and some Eleventy configuration updates.

Enabling review apps #

To configure Review Apps they must be enabled for the project, which can be done per the instructions in the GitLab documentation.

The job artifacts workaround for review apps #

Without a dedicated means of deploying a Review App for a GitLab Pages site, the workaround used here relies on the fact that CI job artifacts are actually served by the GitLab Pages server with a URL following the format https://<namespace>.<pages_domain>/-/<project>/-/jobs/<job_id>/artifacts/<file path>.

As an example, for this site's GitLab project that URL would be something like https://aarongoldenthal.gitlab.io/-/aarongoldenthal/-/jobs/1234567890/artifacts/public/index.html (based on the job ID).

For an Eleventy site, the initial URL should be the index.html file in the project root directory. For consistency with the pages job, it's assumed that the project is in the public/ directory, so the <file path> should be public/index.html. The other parameters - <namespace>, <pages_domain>, <project>, and <job_id> - can all be taken from GitLab predefined variables, which provides the most generic job definition allowing it to be re-used in multiple projects on either gitlab.com or a self-hosted GitLab instance.

The differences from the pages job are:

  • The rules are set to only run on merge request pipelines. If merge request pipelines are not being used, details are also provided below for using branch pipelines instead.
  • For a Review App, a unique environment:name must be set up. In this case the CI_COMMIT_REF_SLUG is used, which is the branch name. This allows multiple simultaneous Review Apps, but only one per branch. The url is set to the format described previously.

The complete pages_review_app job is shown below.

pages_review_app:
  image: node:lts-alpine
  stage: deploy
  rules:
    # If not using MR pipelines this can be set to run on branch pipelines
    # that are not the default branch with:
    # if: $CI_COMMIT_BRANCH && $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH
    - if: $CI_MERGE_REQUEST_ID
  variables:
    BUILD_TYPE: pages
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    deployment_tier: testing
    url: https://$CI_PROJECT_ROOT_NAMESPACE.$CI_PAGES_DOMAIN/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/public/index.html
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - public

At this point running a merge request pipeline exposes a Review App, which opens the home page, but there are two issues that still need to be addressed:

  • The home page opens properly with the Review App, but any root-relative path references are incorrect. They are pointing to locations relative to /, but with this URL format the / directory is actually the public/ directory, which is nested 7 directories deep. This affects any root-relative paths in <a>, <img>, <link>, <script> or other tags.
  • Links between pages point to directories, expecting the web server to serve default pages. In the case of artifacts this does not occur, so a 404 response is returned.

Both of these issues are addressed in the following sections.

Fixing path references with eleventy configuration #

To resolve the root-relative path issues, the Eleventy configuration needs to be updated to dynamically set the pathPrefix property. This updates all of the URLs to reflect the site being deployed in a sub-directory. For test or production (i.e GitLab Pages) builds, the default / path is used, but it's set to the appropriate directory for Review Apps.

To simplify working with GitLab predefined variables the gitlab-ci-env package is used in this example. This package returns an object with all GitLab predefined variable values, hierarchically organized by context, which can simplify cases where a lot of predefined variables are used. If this is not desired, see the package documentation for a complete mapping of properties to the actual predefined environment variable names.

The buildType uses the previously specified BUILD_TYPE variable. If this is not specified, it defaults to test, intended to be a configuration used for other testing. The isReviewApp value is set to true if the build is run in CI in a merge request pipeline, which matches the preceding CI job definition. In this case these values are only used to set the pathPrefix, but could be exposed for use in other places (for example excluding analytics code in Review Apps).

The pathPrefix is finally set using these two values. If the buildType is not pages, or if isReviewApp is false, the default path is used. Otherwise, this is a build for a Review App and the path prefix as specified in the pages_review_app job is used. The pathPrefix finally needs to be specified in the object returned from the Eleventy configuration file.

As noted previously, feature branch pipelines could be used instead of merge request pipelines. In that case the logic for isReviewApp should be updated to match the pages_review_app rules.

In this case the output directory is set to public as noted in the preceding CI jobs. This can be set to other values, but the resulting site output directory must be copied to public in both Pages CI jobs to be collected as CI artifacts.

The complete Eleventy configuration updates are shown below.

const gitlabEnv = require('gitlab-ci-env');

const defaultBuildType = 'test';
const defaultPathPrefix = '/';

const buildType = process.env.BUILD_TYPE || defaultBuildType;

const isReviewApp = gitlabEnv.ci.isCI && gitlabEnv.ci.mergeRequest.id;

const pathPrefix =
    buildType === 'pages' && isReviewApp
        ? `/-/${gitlabEnv.ci.project.name}/-/jobs/${gitlabEnv.ci.job.id}/artifacts/public/`
        : defaultPathPrefix;

module.exports = function (eleventyConfig) {
    ...
    return {
        pathPrefix,
        ...
        dir: {
            ...
            output: 'public'
        }
    };
};

The last issue to resolve is that links within the site point to directories, and GitLab does not serve the default index.html files. To resolve this, the output HTML files from the Eleventy build are post-processed to update the links.

The find command is used to search the ./public directory (find ./public) for any files (-type f) whose name matches *.html (-name "*.html"). Then a command is executed on each file (-exec <command> "{}" +).

The sed command is used to update the links. The -i argument makes the changes to the files in place. The -E argument allows the use of extended regular expressions, which provides more consistent behavior of ? and + (in this case they must be escaped to be literal values, otherwise they behave as special characters). The regular expression finds any <a> element with an href value with a root-relative path (that is, starting with /), which includes the public directory, and an optional subdirectory. It also captures any other attributes preceding or following the href attribute. The sed command then inserts index.html before the href closing ".

For a more detailed explanation of the complete regular expression a great tool is regex101, which breaks it down in detail and allows interactive testing. See this link for this specific regular expression and some examples.

This command is run in after_script so that the same script can be re-used for all Pages jobs.

pages_review_app:
  ...
  after_script:
    - >
      find ./public -type f -name "*.html" -exec
      sed -i -E 's/(<a[^>]*href="\/.+\/public\/(.*\/)?)("[^>]*>)/\1index.html\3/' "{}" +

Refactoring the pages jobs #

Looking at both complete jobs there are a lot of common elements, so the final jobs below extract those common components to a template .pages which is then extended for both the pages and pages_review_app jobs.

.pages:
  image: node:lts-alpine
  stage: deploy
  variables:
    BUILD_TYPE: pages
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - public

pages:
  extends: .pages
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  environment:
    name: production
    deployment_tier: production
    url: https://<site_url>/

pages_review_app:
  extends: .pages
  rules:
    - if: $CI_MERGE_REQUEST_ID
  environment:
    name: review/$CI_COMMIT_REF_SLUG
    deployment_tier: testing
    url: https://$CI_PROJECT_ROOT_NAMESPACE.$CI_PAGES_DOMAIN/-/$CI_PROJECT_NAME/-/jobs/$CI_JOB_ID/artifacts/public/index.html
  after_script:
    - >
      find ./public  -type f -name "*.html" -exec sed -i -E
      's/(<a[^>]*href="\/.+\/public\/(.*\/)?)("[^>]*>)/\1index.html\3/' "{}" +