Using GitHub Actions to Publish This Website

September 11, 2022

I have recently moved this website from DreamHost to AWS. While I was able to automate the setup of the infrastructure, I was still deploying changes manually. It is not a very cumbersome process and it involves the following steps after a change is created:

  1. Build the website;
  2. Sync the new website contents with the main S3 bucket;
  3. Invalidate the cache of the non-www CloudFront distribution;
  4. Invalidate the cache of the www CloudFront distribution.

In its essence, this involves running the following 4 commands, in sequence:

$ bundle exec jekyll build
$ aws s3 sync _site/ s3://jcazevedo.net/ --delete
$ aws cloudfront create-invalidation --distribution-id E1M51KVTH60PJ5 --paths '/*'
$ aws cloudfront create-invalidation --distribution-id E2YP0O47Y4BTWK --paths '/*'

This is not terrible to run each time I introduce a new change, but it would be easier if I could make it so that every push to the master branch of the repository which holds the contents of the website would trigger a deploy. Fortunately we can use GitHub Actions for this.

Setting Up the GitHub Action

In order to set that up, we first need to create a workflow. Workflows live in the .github/workflows folder, and that is where I have created the deploy.yml file.

We start by giving the workflow a name:

name: Deploy

Then, we setup which actions trigger a workflow run. In this case, I want every push to the master branch to trigger it:

on:
  push:
    branches:
      - master

Following that, we can start defining our job. In this case, we need to specify in which environment the job should run and the list of steps that comprise it. We’re OK with running on the latest Ubuntu version:

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      (...)

To build the website, we need to have 3 steps: (1) checkout the repository, (2) setup ruby and install dependencies and (3) run bundle exec jekyll build:

- uses: actions/checkout@v3

- uses: ruby/setup-ruby@v1
  with:
    ruby-version: 3.0
    bundler-cache: true

- run: bundle exec jekyll build

Once the site is built, we need to publish it to S3 and invalidate the caches of the CloudFront distributions. The AWS Command Line Interface is already available in GitHub-hosted virtual environments, so we just need to set up the credentials we want to use. In this case, we want to reference some repository secrets which we will set up later:

- uses: aws-actions/configure-aws-credentials@v1
  with:
    aws-access-key-id: $
    aws-secret-access-key: $
    aws-region: us-east-1

With the credentials set up, we can run the commands we previously listed:

- run: aws s3 sync _site/ s3://jcazevedo.net/ --delete
- run: aws cloudfront create-invalidation --distribution-id E1M51KVTH60PJ5 --paths '/*'
- run: aws cloudfront create-invalidation --distribution-id E2YP0O47Y4BTWK --paths '/*'

The full YAML for the workflow definition is as follows:

name: Deploy

on:
  push:
    branches:
      - master

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: 3.0
          bundler-cache: true

      - run: bundle exec jekyll build

      - uses: aws-actions/configure-aws-credentials@v1
        with:
          aws-access-key-id: ${{secrets.AWS_ACCESS_KEY_ID}}
          aws-secret-access-key: ${{secrets.AWS_SECRET_ACCESS_KEY}}
          aws-region: us-east-1

      - run: aws s3 sync _site/ s3://jcazevedo.net/ --delete
      - run: aws cloudfront create-invalidation --distribution-id E1M51KVTH60PJ5 --paths '/*'
      - run: aws cloudfront create-invalidation --distribution-id E2YP0O47Y4BTWK --paths '/*'

Creating a User for GitHub Actions

To set up the credentials this workflow is going to use to interact with AWS, I wanted to create a user with permissions to interact with the relevant S3 bucket and CloudFront distributions only. To do that, I have added the following to the Terraform definition (refer to the previous post for more details on the existing Terraform definition):

resource "aws_iam_user" "github-actions" {
  name = "github-actions"
}

resource "aws_iam_access_key" "github-actions" {
  user = aws_iam_user.github-actions.name
}

output "github-actions_aws_iam_access_key_secret" {
  value = aws_iam_access_key.github-actions.secret
  sensitive = true
}

resource "aws_iam_user_policy" "github-actions" {
  name = "github-actions_policy"
  user = aws_iam_user.github-actions.name
  policy = data.aws_iam_policy_document.github-actions_policy.json
}

data "aws_iam_policy_document" "github-actions_policy" {
  statement {
    sid = "S3Access"

    actions = [
      "s3:PutBucketWebsite",
      "s3:PutObject",
      "s3:PutObjectAcl",
      "s3:GetObject",
      "s3:ListBucket",
      "s3:DeleteObject"
    ]

    resources = [
      "${aws_s3_bucket.jcazevedo_net.arn}",
      "${aws_s3_bucket.www_jcazevedo_net.arn}",
      "${aws_s3_bucket.jcazevedo_net.arn}/*",
      "${aws_s3_bucket.www_jcazevedo_net.arn}/*"
    ]
  }

  statement {
    sid = "CloudFrontAccess"

    actions = [
      "cloudfront:GetInvalidation",
      "cloudfront:CreateInvalidation"
    ]

    resources = [
      "${aws_cloudfront_distribution.root_s3_distribution.arn}",
      "${aws_cloudfront_distribution.www_s3_distribution.arn}"
    ]
  }
}

This creates a new IAM user, attaches a policy to it that gives it access to the relevant S3 and CloudFront resources, and creates a new access key which we will set up as a secret in our GitHub repository. The secret access key gets stored in the Terraform state, but we define an output that allows us to read it with terraform output -raw github-actions_aws_iam_access_key_secret.

With the GitHub secrets appropriately set up, we now have a workflow that publishes this website whenever a new commit is pushed to the master branch.