Creating a static blog with Hugo on Firebase Hosting delivered with Drone CI

· by Adrian Todorov · Read in about 16 min · (3268 words) ·

Preface

Hello there. After years of hesitation, i finally got the courage to start my blog as an excuse to play with some cool tech i don’t have the chance to do at work. Of course, there is no fun in using something easy like WordPress, so i chose Hugo, a static site generator. This way i can have everything in Git, and have a CI/CD pipeline that deploys it somewhere.

The stack

I’ve been eyeing Hugo for more than a year, and i prefer its syntax and flexibility to Jekyll, so that’s that. For Git i GitHub should do the trick; for CI/CD, i’ve always liked Drone on paper - configuration is in simple .yml file per repo, everything is a plugin (which in itself is just a Docker container), and it seems like a good opportunity to give it an actual go.

Originally i thought about using AWS S3 for hosting the static files, and CloudFront for CDN and SSL, but i already work with AWS and it’s something i’ve already done, so there was no challenge. Google Cloud Platform it is then, with it’s nice “Always Free” tier.

However, upon some reading on the Google Cloud Storage (GCS) docs and Henry Lawson’s blog post on the subject i realised that blindly applying what i know about AWS won’t work. Like him, i imagined it the following way:

  • Static content gets uploaded to GCS

  • Google Cloud CDN serves it with a custom SSL cert

However, there are a few problems with that - GCS doesn’t need CDN because it does caching by default, but it doesn’t support SSL with custom domains. Not a problem, i’ll just use Cloud CDN for that! Well, apparently it requires a Load Balancer, which costs money and seems way too overkill.

So, Firebase Hosting enters the stage. It’s oriented towards static site hosting, supports SSL with custom domains and also has an always free tier. Furthermore, the Firebase suite has some pretty interesting products which can come to use for a static blog/ongoing experiment, most notably Cloud Firesotre, a managed NoSQL database, which, coupled with Cloud Functions i can use to add some dynamic parts.

Roadmap

I’m assuming you already have Hugo installed and running - if that’s not the case, you can check out Hugo’s Quick Start guide.

I’ll start by setting up Firebase and DroneCI, and then creating a pipeline to generate the static files with Hugo and deploy them to Firebase Hosting. Once that’s up and running, i’ll look into adding the following:

  • tests before deploying the pipeline - a spellchecker, for instance

  • a dynamic comment section and a newsletter with the help of Cloud Functions and other parts of the Firebase/Google Cloud Platform suite

  • search with lunr.js, fuse.js, algolia or something else

So, let’s get started.

Getting started with Firebase Hosting

First you need to create your Firebase project. To do that, you have to connect to the Firebase Console with a Google account, and click on the big “Add project” button. There you have to create a project with a cool name (in my case it’s just “Blog”, you can be more original than that), or use an existing one from Google Cloud Platform. Once its created / selected, we arrive at the pretty slick looking Firebase console. There are plenty of features, but for now i’ll just stick to the CLI (easier to document, gitify, redo one day if necessary).

To start using the Firebase CLI, you need to have Node.js and npm installed, afterwards it’s as easy a single command to install and another one to login (which opens a page where you sign in with your Google account and authorise the CLI to access your Google Cloud Platform):

npm install -g firebase-tools
firebase login

Then you have to create a folder which will contain our project (or if you already have a folder with Hugo, just cd inside), and initialise the Firebase CLI in it:

mkdir blog && cd blog
firebase init

The CLI then launches an interactive setup which asks questions, like which services do we want to setup (for now just Hosting ), what Firebase project are we working on, security rules, the folder we want to publish to Firebase Hosting (public by default and a sensible choice considering it’s also Hugo’s default):

You're about to initialize a Firebase project in this directory:

  /home/ato/blog

? Which Firebase CLI features do you want to setup for this folder?
Press Space to select features, then Enter to confirm your choices.
 ◯ Database: Deploy Firebase Realtime Database Rules
 ◯ Firestore: Deploy rules and create indexes for Firestore
 ◯ Functions: Configure and deploy Cloud Functions
❯◉ Hosting: Configure and deploy Firebase Hosting sites
 ◯ Storage: Deploy Cloud Storage security rules

=== Project Setup

First, let's associate this project directory with a Firebase project.
You can create multiple project aliases by running firebase use --add,
but for now we'll just set up a default project.

? Select a default Firebase project for this directory: (Use arrow keys)
  [don't setup a default project]
❯ Blog (blog-314450)
  [create a new project]

=== Hosting Setup

Your public directory is the folder (relative to your project directory) that
will contain Hosting assets to be uploaded with firebase deploy. If you
have a build process for your assets, use your build's output directory.

? What do you want to use as your public directory? (public)
? Configure as a single-page app (rewrite all urls to /index.html)? (y/N)
✔  Wrote public/404.html
✔  Wrote public/index.html

i  Writing configuration info to firebase.json...
i  Writing project information to .firebaserc...

✔  Firebase initialization complete!

So now you have the Firebase CLI installed and configured.

Since it installed a default index.html in the public folder (public in my case), you can test if everything is running fine locally with the following command:

firebase serve --only hosting
=== Serving from '/home/ato/blog'...

i  hosting[blog-314450]: Serving hosting files from: public
✔  hosting[blog-314450]: Local server: http://localhost:5000

Opening http://localhost:5000 should give us the default Firebase page.

Now, let’s deploy it to Firebase Hosting:

firebase deploy                                       
=== Deploying to 'blog-314450'...
i  hosting[blog-314450]: beginning deploy...
i  hosting[blog-314450]: found 2 files in public
✔  hosting[blog-314450]: file upload complete
i  hosting[blog-314450]: finalizing version...
✔  hosting[blog-314450]: version finalized
i  hosting[blog-314450]: releasing new version...
✔  hosting[blog-314450]: release complete

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/blog-314450/overview
Hosting URL: https://blog-314450.firebaseapp.com

If you go check the URL at the end, you should see the same default Firebase index.html.

So far, Firebase Hosting is up and running. Time to deploy our Hugo generated website to it!

Generating static files with Hugo and deploying them on Firebase Hosting

Generating static HTML with Hugo is as simple as running a single command, which will put the files in the public folder (by default, modifiable with the publishDir parameter in your site’s config):

hugo
Building sites …
                   | EN  
+------------------+----+
  Pages            | 37  
  Paginator pages  |  0  
  Non-page files   |  0  
  Static files     | 14  
  Processed images |  0  
  Aliases          | 13  
  Sitemaps         |  1  
  Cleaned          |  0  

And that’s it, you should now have the HTML ready in the public folder, and all that’s left is to run firebase deploy and deploy to your website on Firebase Hosting:

➜  blog git:(master) ✗ firebase deploy                                                                                                                                                                

=== Deploying to 'blog-314450'...

i  deploying hosting

i  hosting[blog-314450]: beginning deploy...
i  hosting[blog-314450]: found 67 files in public
✔  hosting[blog-314450]: file upload complete
i  hosting[blog-314450]: finalizing version...
✔  hosting[blog-314450]: version finalized
i  hosting[blog-314450]: releasing new version...
✔  hosting[blog-314450]: release complete

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/blog-314450/overview
Hosting URL: https://blog-314450.firebaseapp.com

Refreshing the webpage should now show your webite up and running, fully static and for free.

Now, that was simple enough, but you don’t want to actually do this manually every time, so let’s set up a CI/CD pipeline that will rebuild the Hugo site and deploy it to Firebase every time there’s a new commit (it goes without saying, Hugo uses text files for everything, and if it’s text and it matters, it should be stored in Git).

Getting started with Drone

Drone is a cloud-native CI/CD system, heavily based around Docker. Pipelines are defined with YAML files, contain multiple stages and each stage is a plugin (which is a Docker container that uses environment variables to read it’s configuration and it does stuff). There’s a plethora of official plugins, and it’s pretty easy to create a new one in Golang or even Bash - basically anything that could run inside Docker. There’s already an existing plugin for Hugo, which will handle the generation of static files.

Note: at the time of writing, the latest version of Drone is 0.8

“Installation” is pretty easy - you just have to run two Docker containers available on Docker Hub. It’s even easier with Docker Compose, which orchestrates that with a relatively simple YAML file (for getting started with it, you can check Digital Ocean’s great installation guide ).

After you have Docker Compose up and running, create the following docker-compose.yml file:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Docker Compose configuration version
version: '2'

# With the services block we define each "service" (Docker container and associated configuration) we want to run
services:
  drone-server: # drone-server, which is the backend
    image: drone/drone:0.8 # the Docker image we're using, which is drone/drone (group/project:version) from the public Docker Hub
    ports: # the list of ports to expose - with an optional host-port:container port mapping
      - 80:8000
      - 9000
    volumes: # Docker Volumes, the way to manage persistent data, in this case Drone's internal SQLite database
      - drone-server-data:/var/lib/drone/
    restart: always # If the container dies / crashes, dockerd will restart it
    environment: # Drone-specific environment variables we use to configure our container
      - DRONE_OPEN=true # is our Drone instance publicly available, true or false
      - DRONE_HOST=${DRONE_HOST} # the URL on which Drone will be accessible, in a <scheme>://<hostname> format
      - DRONE_SECRET=${DRONE_SECRET} # a random secret string shared between drone and drone-agent to secure communication
      # activate GitHub authentication for Drone - http://docs.drone.io/install-for-github/
      - DRONE_GITHUB=true
      - DRONE_GITHUB_CLIENT=${DRONE_GITHUB_CLIENT}
      - DRONE_GITHUB_SECRET=${DRONE_GITHUB_SECRET}

  drone-agent: # drone-agent, which is the frontend
    image: drone/agent:0.8 #
    command: agent # a command to execute upon starting the container, in this case launching Drone Agent
    restart: always
    depends_on: # explicit dependency to make sure the Agent will start only after Drone Server is alive and well
      - drone-server
    volumes: # we're passing the Docker socket so that Drone Agent can manage the host's dockerd and spawn containers on it
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      - DRONE_SERVER=drone-server:9000 # drone-server will resolve to the drone-server container's IP automagically
      - DRONE_SECRET=${DRONE_SECRET} # same secret we passed to drone-server
volumes:  # we define the drone-server-data volume
  drone-server-data:

Highlighted are the lines you probably should change, as well as those that require your input - ${SOMETHING} needs to be replaced with the appropriate value (GitHub credentials, Drone URL, etc.). The most important ones are DRONE_SECRET and the version control configuration (GitHub, GitLab, Gitea, Gogs and BitBucket Cloud are supported ).

Once you’ve modified the file to your liking, run the following command to get everything up:

docker-compose up

If everything is fine, you should see something like this (basically docker-compose creating the Docker containers and what they need to run(virtual network, volume) and then Drone’s logs, which are pretty verbose ):

Creating network "dronecompose_default" with the default driver
Creating dronecompose_drone-server_1
Creating dronecompose_drone-agent_1
Attaching to dronecompose_drone-server_1, dronecompose_drone-agent_1
drone-server_1  | [GIN-debug] [WARNING] Running in "debug" mode. Switch to "release" mode in production.
drone-server_1  |  - using env:	export GIN_MODE=release
drone-server_1  |  - using code:	gin.SetMode(gin.ReleaseMode)
drone-server_1  |
drone-server_1  | [GIN-debug] GET    /logout                   --> github.com/drone/drone/server.GetLogout (12 handlers)
drone-server_1  | [GIN-debug] GET    /login                    --> github.com/drone/drone/server.HandleLogin (12 handlers)
drone-server_1  | [GIN-debug] GET    /api/user                 --> github.com/drone/drone/server.GetSelf (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/user/feed            --> github.com/drone/drone/server.GetFeed (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/user/repos           --> github.com/drone/drone/server.GetRepos (13 handlers)
drone-server_1  | [GIN-debug] POST   /api/user/token           --> github.com/drone/drone/server.PostToken (13 handlers)
drone-server_1  | [GIN-debug] DELETE /api/user/token           --> github.com/drone/drone/server.DeleteToken (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/users                --> github.com/drone/drone/server.GetUsers (13 handlers)
drone-server_1  | [GIN-debug] POST   /api/users                --> github.com/drone/drone/server.PostUser (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/users/:login         --> github.com/drone/drone/server.GetUser (13 handlers)
drone-server_1  | [GIN-debug] PATCH  /api/users/:login         --> github.com/drone/drone/server.PatchUser (13 handlers)
drone-server_1  | [GIN-debug] DELETE /api/users/:login         --> github.com/drone/drone/server.DeleteUser (13 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name   --> github.com/drone/drone/server.PostRepo (16 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name   --> github.com/drone/drone/server.GetRepo (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/builds --> github.com/drone/drone/server.GetBuilds (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/builds/:number --> github.com/drone/drone/server.GetBuild (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/logs/:number/:pid --> github.com/drone/drone/server.GetProcLogs (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/logs/:number/:pid/:proc --> github.com/drone/drone/server.GetBuildLogs (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/files/:number --> github.com/drone/drone/server.FileList (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/files/:number/:proc/\*file --> github.com/drone/drone/server.FileGet (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/secrets --> github.com/drone/drone/server.GetSecretList (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/secrets --> github.com/drone/drone/server.PostSecret (16 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/secrets/:secret --> github.com/drone/drone/server.GetSecret (16 handlers)
drone-server_1  | [GIN-debug] PATCH  /api/repos/:owner/:name/secrets/:secret --> github.com/drone/drone/server.PatchSecret (16 handlers)
drone-server_1  | [GIN-debug] DELETE /api/repos/:owner/:name/secrets/:secret --> github.com/drone/drone/server.DeleteSecret (16 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/registry --> github.com/drone/drone/server.GetRegistryList (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/registry --> github.com/drone/drone/server.PostRegistry (16 handlers)
drone-server_1  | [GIN-debug] GET    /api/repos/:owner/:name/registry/:registry --> github.com/drone/drone/server.GetRegistry (16 handlers)
drone-server_1  | [GIN-debug] PATCH  /api/repos/:owner/:name/registry/:registry --> github.com/drone/drone/server.PatchRegistry (16 handlers)
drone-server_1  | [GIN-debug] DELETE /api/repos/:owner/:name/registry/:registry --> github.com/drone/drone/server.DeleteRegistry (16 handlers)
drone-server_1  | [GIN-debug] PATCH  /api/repos/:owner/:name   --> github.com/drone/drone/server.PatchRepo (16 handlers)
drone-server_1  | [GIN-debug] DELETE /api/repos/:owner/:name   --> github.com/drone/drone/server.DeleteRepo (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/chown --> github.com/drone/drone/server.ChownRepo (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/repair --> github.com/drone/drone/server.RepairRepo (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/move --> github.com/drone/drone/server.MoveRepo (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/builds/:number --> github.com/drone/drone/server.PostBuild (16 handlers)
drone-server_1  | [GIN-debug] DELETE /api/repos/:owner/:name/builds/:number --> github.com/drone/drone/server.ZombieKill (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/builds/:number/approve --> github.com/drone/drone/server.PostApproval (16 handlers)
drone-server_1  | [GIN-debug] POST   /api/repos/:owner/:name/builds/:number/decline --> github.com/drone/drone/server.PostDecline (16 handlers)
drone-agent_1   | {"time":"2018-09-26T20:52:39Z","level":"debug","message":"request next execution"}
drone-server_1  | [GIN-debug] DELETE /api/repos/:owner/:name/builds/:number/:job --> github.com/drone/drone/server.DeleteBuild (16 handlers)
drone-server_1  | [GIN-debug] DELETE /api/repos/:owner/:name/logs/:number --> github.com/drone/drone/server.DeleteBuildLogs (16 handlers)
drone-server_1  | [GIN-debug] GET    /api/badges/:owner/:name/status.svg --> github.com/drone/drone/server.GetBadge (12 handlers)
drone-server_1  | [GIN-debug] GET    /api/badges/:owner/:name/cc.xml --> github.com/drone/drone/server.GetCC (12 handlers)
drone-server_1  | [GIN-debug] POST   /hook                     --> github.com/drone/drone/server.PostHook (12 handlers)
drone-server_1  | [GIN-debug] POST   /api/hook                 --> github.com/drone/drone/server.PostHook (12 handlers)
drone-server_1  | [GIN-debug] GET    /stream/events            --> github.com/drone/drone/server.EventStreamSSE (12 handlers)
drone-server_1  | [GIN-debug] GET    /stream/logs/:owner/:name/:build/:number --> github.com/drone/drone/server.LogStreamSSE (15 handlers)
drone-server_1  | [GIN-debug] GET    /api/info/queue           --> github.com/drone/drone/server.GetQueueInfo (13 handlers)
drone-server_1  | [GIN-debug] GET    /authorize                --> github.com/drone/drone/server.HandleAuth (12 handlers)
drone-server_1  | [GIN-debug] POST   /authorize                --> github.com/drone/drone/server.HandleAuth (12 handlers)
drone-server_1  | [GIN-debug] POST   /authorize/token          --> github.com/drone/drone/server.GetLoginToken (12 handlers)
drone-server_1  | [GIN-debug] GET    /api/builds               --> github.com/drone/drone/server.GetBuildQueue (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/         --> github.com/drone/drone/server/debug.IndexHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/heap     --> github.com/drone/drone/server/debug.HeapHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/goroutine --> github.com/drone/drone/server/debug.GoroutineHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/block    --> github.com/drone/drone/server/debug.BlockHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/threadcreate --> github.com/drone/drone/server/debug.ThreadCreateHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/cmdline  --> github.com/drone/drone/server/debug.CmdlineHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/profile  --> github.com/drone/drone/server/debug.ProfileHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/symbol   --> github.com/drone/drone/server/debug.SymbolHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] POST   /api/debug/pprof/symbol   --> github.com/drone/drone/server/debug.SymbolHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /api/debug/pprof/trace    --> github.com/drone/drone/server/debug.TraceHandler.func1 (13 handlers)
drone-server_1  | [GIN-debug] GET    /metrics                  --> github.com/drone/drone/server/metrics.PromHandler.func1 (12 handlers)
drone-server_1  | [GIN-debug] GET    /version                  --> github.com/drone/drone/server.Version (12 handlers)
drone-server_1  | [GIN-debug] GET    /healthz                  --> github.com/drone/drone/server.Health (12 handlers)

The first part indicates which container is the log coming from (most of them during startup are from drone-server; during builds they’re mostly on the agent side), what system is it coming from (GIN is an HTTP framework for Golang) and various details. All seems fine, so let’s do our first pipeline!

Creating your first pipeline with Drone

Connect to Drone’s web UI, which is available at DRONE_HOST. Once there, it will automatically log you in / demand that you log in with your version control sytem (Drone doesn’t handle authentification, it delegates that part to the VCS). After that, you can enable DroneCI on the projects you want it, add a .drone.yml file, commit it to master and off we go!

A .drone.yml file is just a bunch of YAML which defines our pipeline and its stages (which can be based on regular Docker containers with the commands parameter or special Drone plugins (which are special Docker containers)), with conditions (only run stage X on master/tag; if the pipeline succeeds/fails notify via Slack, etc. etc.)

pipeline:
  hugo:
    image: plugins/drone-hugo:latest
    validate: true

This defines a pipeline with a single stage, called “hugo”, which uses the plugins/drone-hugo:latest image from DockerHub (if you specify the full URL it can be a custom Docker Registry), with the validate:true option (it will check the configuration files for errors). You can read the full documentation of the Drone Hugo Plugin, but that’s the base, with optional parameters like theme, buildDrafts, etc.

So now all that is left is to add the .drone.yml file to your repo and push it to GitHub/equivalent.

Commit and push, and now the pipeline should run successfully:

+ hugo check
Contains some verification checks

+ hugo

                   | EN  
+------------------+----+
  Pages            | 22  
  Paginator pages  |  0  
  Non-page files   |  0  
  Static files     | 14  
  Processed images |  0  
  Aliases          |  7  
  Sitemaps         |  1  
  Cleaned          |  0  

Total in 312 ms

Now that you have the static files generated, the next step is having them deployed to Firebase Hosting.

Adding a deploy to Firebase step in our pipeline

First you need to deal with authentification. Firebase supports token authentification (instead of with your regular Google Account), and for that you need to run the following command:

firebase login:ci

Which will open a browser window, and guide you through connecting to your Google Account and giving the appropriate rights to the token, and in the end print out the token.

As you know, every time you commit credentials to Git, little kittens die. Luckily Drone can easily manage secrets with the Drone CLI (which you need tp install and login into first). To add a new secret to the Drone store, use the drone secret add command:

drone secret add \
  -repository sofixa/blog \
  -image sofixa/drone-firebase \
  -name token \
  -value "$FIREBASE_TOKEN"

This would add a secret available to the sofixa/blog repository, only on stages running with the sofixa/drone-firebase image, with the name token and value of $FIREBASE_TOKEN(which you should replace with the token you got from firebase login:ci).

To add a second stage which deploys to Firebase using the token secret you’ve just added, modify the .drone.yaml :

pipeline:
  hugo:
    image: plugins/hugo:latest
    validate: true
  firebase:
    image: sofixa/drone-firebase
    project_id: blog-314450
    message: Autodeploy of commit $$COMMIT
    targets: hosting
    # this will extrapolate the token secret as a parameter with its value
    secrets:
      - source: token
        target: plugin_token

Now commit and push to your Git Repository, and Drone should pick up an extra step (firebase) and deploy your Hugo generated static files to Firebase hosting with a similar output:

Now using project blog-314450

=== Deploying to 'blog-314450'...

i  deploying hosting
i  hosting[blog-314450]: beginning deploy...
i  hosting[blog-314450]: found 47 files in public
i  hosting: adding files to version [0/47] (0%)
✔  hosting[blog-314450]: file upload complete
i  hosting[blog-314450]: finalizing version...
✔  hosting[blog-314450]: version finalized
i  hosting[blog-314450]: releasing new version...
✔  hosting[blog-314450]: release complete

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/blog-314450/overview
Hosting URL: https://blog-314450.firebaseapp.com

And that’s it! You know have a static website with Hugo on Firebase Hosting, delivered automatically every time you commit in your repository.

Possible improvements: * spellcheck stage * scheduled runs to pick up planned articles with a future publish date * …