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
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
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:

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":

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
.

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
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;
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
},
/* ... */
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
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/
test
This job just calls the test:ci
task that came with the Massively theme.
test:
stage: test
script:
- yarn test:ci
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/
deploy
At last, the fun part of actually deploying the theme to my blog!
It calls that new yarn deploy
task 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
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