February 25, 2020

Using GitLab CI to deploy my Ghost theme

See how I use GitLab CI to deploy my customized theme to my Ghost.org powered blog.

I host my blog on Ghost.org, and I'm using a (slightly, but still) customized theme. Custom theme packages (i.e. a zip file) need to be uploaded to the Ghost's Admin using a browser.

There is an official integration provided by Ghost.org for deploying your custom theme to your Ghost powered website automatically using GitHub Actions.

The thing is, I'm using GitLab for most of my personal and professional source code management needs. The sources for the customized theme I'm using, which is Ghost's official Massively with rather light adjustments for my Blog, are no exception and are on my personal GitLab account.

I could switch that repository into my GitHub account and be done with it, but where's the fun in that?
Rather, let's see if we can use GitLab CI to build and deploy my theme!

Status Quo

Forking a theme

I'm using a "manual" fork of the Massively theme. By "manual" I mean that it was not created by clicking "fork" on GitHub. Instead I cloned that repository from GitHub onto my local machine, then added my repository for it that I created on GitLab as a second git remote, like so:

# clone offical massively theme
git clone git@github.com:TryGhost/Massively.git ghost-theme

# add my "ghost-theme" repository I created on GitLab.com as a second git remote I call "custom"
git remote add custom git@gitlab.com:wolfgangrittner/ghost-theme.git

fork theme

This setup allows me to use GitLab, while still being able to pull in upstream changes from the official repository.

# now I can pull any updates from the official sources
git pull origin master

# and I can push any changes I made for my site to my GitLab repository
git push custom
get upstream changes - save customizations

Manual deployment

After I'm done updating my theme I would run the build action that comes with the theme, which packages up everything into a zip file in the project's dist folder.
That file needs to be uploaded manually to the Ghost admin interface.

That works, but it's getting a bit cumbersome and is no fun at all.

Automate ALL THE THINGS

I'm a heavy user of GitLab and GitLab CI for a while now, and am working with it on a daily basis. With the latest change to my custom blog theme I decided that I was done with manual theme uploading and wanted to automate the process.
I knew there was an official GitHub action, so I figured this wouldn't be too hard.

Well, it wasn't, but it was not that straight-forward either.

Checking out the GitHub action

Disclaimer: I haven't had the chance to look into GitHub actions much yet, so I've no idea what I'm talking about.

GitHub actions are quite different to what GitLab CI does. It's not necessarily about what you can do with it, but rather how things are done and implemented.

I had a look at the official GitHub action, and dove right into where the action (pun intended) happens:

https://github.com/TryGhost/action-deploy-theme/blob/master/index.js

I was able to navigate the sources and make sense of it. I think.
What the code does is that it creates the theme package zip file and then uses Ghost's Javascript SDK and API to upload the theme to the blog.

GitHub actions vs. GitLab CI

I believe the main difference is that GitLab CI is rather about configuring what needs to happen in what stage, and what stages make up your pipeline, while GitHub actions go beyond that in a way and also provide infrastructure to implement the code that makes things happen, and distribute it.

What do you think? Let me know!

When porting the deploy action to GitLab, I had to find a place for the code that makes the deployment happen first. A GitLab CI file (usually) does not contain much code other than commands to execute during a given CI step.

The Plan

There's already npm/yarn/gulp scripts for developing, building and packaging the theme. The theme uses gulp and includes tasks for building and packaging already. I'm going to leverage those and add a deploy task to gulp, add in the dependencies needed as dev dependencies using yarn, and add a script action to package.json for consistency with existing tasks.

Eventually I need to create a GitLab-CI configuration that calls all those tasks for building, packaging and deploying and be done with it. Easy!

Create a new Ghost custom integration

Same as with the GitHub action, you need to add a new custom integration to your Ghost site to get an API key. This key is later used by the CI task to authenticate to your blog for uploading your theme.

In Ghost Admin, navigate to Integrations and create a new custom integration called "GitLab CI":

Ghost Admin- Integrations - New Custom Integration

You are going to need the Admin API Key and the API URL in the next  step.

Configure GitLab CI variables

Next, copy and paste your API key and the API URL into the GitLab repository's CI / CD environment variables. You find this under Settings > CI / CD / Variables.

I named the variables GHOST_ADMIN_API_KEY and GHOST_API_URL.

GitLab repository's CI/CD settings

These variables will be available to CI tasks as environment variables later.

Adding dependencies

Ghost.org provides a JavaScript SDK that contains a wrapper around Ghost's APIs. The API includes an endpoint that allows for uploading theme packages. That's what the GitHub deploy action uses.

Adding this to my customized theme using yarn:

yarn add tryghost/admin-api --dev
Add Ghost's Admin API SDK as a development dependency

Implement the "deploy" task

I implemented the deploy action as a Gulp task, adding it to the theme's gulpfile.js. The code was adapted from the GitHub action:

const GhostAdminApi = require('@tryghost/admin-api');

async function deploy(done) {
  try {
    const url = process.env.GHOST_API_URL;
    const admin_api_key = process.env.GHOST_ADMIN_API_KEY;
    const themeName = process.env.THEME_NAME || require('./package.json').name;
    const zipFile = `dist/${themeName}.zip`;
    const api = new GhostAdminApi({
      url,
      key: admin_api_key,
      version: 'canary'
    });
    
    await api.themes.upload({file: zipFile});
    console.log(`${zipFile} successfully uploaded.`);
    done();
  } catch(err) {
    console.error(err);
    done(err);
  }
}

exports.deploy = deploy;
Additions to gulpfile.js 

Then I added "deploy" to the script section of package.json, so I can just call yarn deploy from CI later:

/* ... */
"scripts": {
  "dev": "gulp",
  "zip": "gulp zip",
  /* ... */
  "deploy": "gulp deploy" // <= added deploy script
},
/* ... */
package.json (excerpt)

Create a GitLab-CI configuration

GitLab-CI is configured by placing a file called gitlab-ci.yml into the root of your repository.

I want my CI pipeline to test, build and package whenever there are any changes to my theme repository. And I want it to additionally deploy my theme automatically whenever there are changes to the master branch.

My pipeline is divided into four stages, which GitLab-CI will execute sequentially in order:

stages:
  - .pre
  - test
  - build
  - deploy
stages

install

.pre is a built in stage that is always executed first. I use it for a job I called install. This task runs yarn install, which fetches all dependencies for building, packaging and deploying the theme. The result of this task is a node_modules directory which is going to be cached for subsequent pipeline runs to speed things up a bit.
Additionally it is going to be stored with GitLab as an "artifact", which makes it automatically available for the subsequent build and deploy stages.

See Cache vs Artifacts in GitLab's Docs.
tl;dr cache stores stuff for subsequent pipeline runs, while artifacts store files generated by a job and are available to later stages in the same pipeline.
install:
  stage: .pre
  script:
    - yarn install
  artifacts: # cache packages for subsequent _steps_ in the same pipelin run
    paths:
      - node_modules/
    expire_in: 30min
  cache: # caches packages for subsequent _pipeline_ run
    paths:
      - node_modules/
install job

test

This job just calls the test:citask that came with the Massively theme.

test:
  stage: test
  script:
    - yarn test:ci
test job

build

The build job calls the zip task that also came with the theme. This will build the theme and package it up as a zip file. That file is going to be saved with GitLab as an artifact, which will automatically be handed down to subsequent tasks.

Note that this task will run for all branches and commits. The package will be available for download from GitLab's UI if needed.

build:
  stage: build
  script:
    - yarn zip
  artifacts:
    expire_in: 1 day
    paths:
      - dist/
build job

deploy

At last, the fun part of actually deploying the theme to my blog!

It calls that new yarn deploytask I added earlier, which will take the theme package zip file created by the build job and upload it to the ghost blog.

I don't want all changes I make in any branch to be deployed to my production blog right away, so this job will only be executed for changes on the master branch.
Whenever I commit something to master or merge in a branch to master, GitLab-CI will automatically build and deploy the theme. Automation ftw! 🎉

deploy:
  stage: deploy
  script:
    - yarn deploy
  only: # only deploy master branch
    refs:
      - master
deploy job

My full gitlab-ci.yml file for reference:

# NOTE: needs GHOST_API_URL and GHOST_ADMIN_API_KEY set via gitlab-ci variables

# Docker image to use for all tasks
image: node:10

# Environment variables (in addition to those injected by GitLab-CI like variables configured in Settings > CI/CD > Variables)
variables:
  THEME_NAME: massively-customized

# CI pipeline stages will be executed in order, tasks of the same stage would run in parallel
# .pre is built-in and is always executed first
stages:
  - .pre
  - test
  - build
  - deploy

# Task that installs all dependencies needed to build, package and deploy.
# It will cache its results (i.e. node_modules directory) for subsequent pipeline runs, and it will hand down its results (node_modules) to subsequent tasks in the same pipeline run using the artifacts feature.
install:
  stage: .pre
  script:
    - yarn install
  artifacts: # cache packages for subsequent _steps_ in the same pipelin run
    paths:
      - node_modules/
    expire_in: 30min
  cache: # caches packages for subsequent _pipeline_ run
    paths:
      - node_modules/

# Execute the theme's tests.
test:
  stage: test
  script:
    - yarn test:ci

# Build and package the theme.
# Save the package as an artifact, which will automatically be handed down to subsequent tasks.
# Note that this task will run for all branches and commits. The package will be available for download from GitLab's UI if needed.
build:
  stage: build
  script:
    - yarn zip
  artifacts:
    expire_in: 1 day
    paths:
      - dist/

# Actually deploy the theme.
# Note that this task is only executed for changes on the master branch. Whenever I push a change to master, or merge a branch to master, GitLab-CI will automatically build and deploy the theme to my Ghost blog.
deploy:
  stage: deploy
  script:
    - yarn deploy
  only: # only deploy master branch
    refs:
      - master
gitlab-ci.yml