Intro

For the last few months, I have been working on a static site generator named shazam. It is written in Rust and can be compiled to binaries suitable for Linux, with Windows and MacOS coming soon.

I am all about the DevOps, so of course I wanted to use Github Actions to automagically build and release the binary assets as well as publish a container image to Docker Hub.

In this post, I will show you how I use Github actions to publish binaries for Shazam to Github releases and container images to Docker hub.

Note
The workflows referenced in this post can be found here.

Workflows

Github actions use workflows to define continuous integration tasks. For this project, I have defined 3 workflows.

  • Test Workflow - Runs tests as well as style and correctness checks
  • Tag Workflow - Creates release tags based on the Cargo.toml package version
  • Release Workflow - Creates binaries for Github releases and builds a container image for Docker Hub

The following flowchart shows the Github actions workflow.

blog/rust-binary-and-docker-releases-using-github-actions/github-actions.png

The following points summarize the diagram.

  • A developer creates a pull request
  • The Test Workflow is triggered running automated tests
  • A project owner merges the pull request to the main branch
  • A merge into main triggers the Tag Workflow where a tag is generated from the projects Cargo.toml version and is pushed to the repo
    • The tag is used for both the Github release and the Docker Hub image
  • A successful Tag Workflow triggers the Release Workflow
  • The Release Workflow compiles binary assets and uploads them as project releases to Github
  • A Docker image is generated and uploaded Docker Hub.
Note
I have the main branch protected, which prevents pushing directly into main.

Workflow files are stored in the .github/workflows/ directory in the root of a project.

Test Workflow

The Test Workflow runs the tests, checks formatting and code style correctness with clippy. The workflow runs whenever a pull request is created.

test.yml
name: "Test"

on:
  pull_request:

jobs:
  check:
    name: "Cargo check"
    runs-on: "ubuntu-latest"
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3

      - uses: "actions-rs/toolchain@v1"
        with:
          profile: "minimal"
          toolchain: "stable"
          override: true

      - uses: "actions-rs/cargo@v1"
        with:
          command: "check"

  test:
    name: "Cargo test"
    runs-on: "ubuntu-latest"
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3

      - uses: "actions-rs/toolchain@v1"
        with:
          profile: "minimal"
          toolchain: "stable"
          override: true

      - uses: "actions-rs/cargo@v1"
        with:
          command: "test"

  fmt:
    name: "Cargo format"
    runs-on: "ubuntu-latest"
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3

      - uses: "actions-rs/toolchain@v1"
        with:
          profile: "minimal"
          toolchain: "stable"
          override: true
          
      - run: "rustup component add rustfmt"

      - uses: "actions-rs/cargo@v1"
        with:
          command: "fmt"
          args: "--all -- --check"

  clippy:
    name: "Cargo clippy"
    runs-on: "ubuntu-latest"
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3

      - uses: "actions-rs/toolchain@v1"
        with:
          profile: "minimal"
          toolchain: "stable"
          override: true

      - run: "rustup component add clippy"

      - uses: "actions-rs/cargo@v1"
        with:
          command: "clippy"
          args: "-- -D warnings"

Tag Workflow

The Tag Workflow runs when a pull request is merged into the main branch. A tag is generated based on the project version from the Cargo.toml file and pushed to the repo.

tag.yml
name: "Tag"

on:
  push:
    branches:
      - "main"

jobs:
  create-tag:
    name: "Create tag"
    runs-on: "ubuntu-latest"
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: "Get tag"
        id: "get-tag"
        shell: "bash"
        run: |
          echo PKG_VERSION=$(awk -F ' = ' '$1 ~ /version/ { gsub(/["]/, "", $2); printf("%s",$2) }' Cargo.toml) >> $GITHUB_OUTPUT

      - name: "Set Tag"
        shell: "bash"
        run: |
          git tag v${{ steps.get-tag.outputs.PKG_VERSION }} && git push --tags

Release Workflow

The Release Workflow is where the magic happens. Binary assets are generated and uploaded to the projects Github as releases. A Docker container image is generated and uploaded as a tag.

To prevent infinite loops, workflows only trigger other workflows on limited events. One of these events is the workflow_run event.

The Release Workflow runs when the Tag Worflow is completed. Additionally, the steps require that the Tag Workflow completed successfuly. This is defined with an if condition: if: ${{ github.event.workflow_run.conclusion == 'success' }}.

The other thing to note, is that some steps depend on others. By default, tasks run in parallel, you can control the order tasks are run by declaring the needs parameter. EG: needs: "get-tag". In the below workflow, this creates a dependency for the create-release task, which will wait for the get-tag task to complete before executing.

release.yml
name: "Release"

permissions:
  contents: "write"

on:
  workflow_run:
    workflows: ["Tag"]
    types:
      - "completed"

jobs:
  get-tag:
    name: "Get Tag From Package Version"
    runs-on: "ubuntu-latest"
    outputs:
      pkg-version: ${{ steps.pkg-version.outputs.PKG_VERSION }} 
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      - name: "Get tag"
        id: "pkg-version"
        shell: "bash"
        run: |
          echo PKG_VERSION=$(awk -F ' = ' '$1 ~ /version/ { gsub(/["]/, "", $2); printf("%s",$2) }' Cargo.toml) >> $GITHUB_OUTPUT

  create-release:
    name: "Create release"
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    needs: "get-tag"
    runs-on: "ubuntu-latest"
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3
      
      - name: "Create release"
        uses: "taiki-e/create-gh-release-action@v1"
        with:
          # (optional) Path to changelog.
          # changelog: CHANGELOG.md
          branch: "main"
          ref: refs/tags/v${{ needs.get-tag.outputs.pkg-version }}
          token: ${{ secrets.GITHUB_TOKEN }}

  upload-assets:
    name: "Upload assets to Github releases"
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    needs: 
     - "get-tag"
     - "create-release"
    strategy:
      matrix:
        include:
          - target: "x86_64-unknown-linux-gnu"
            os: "ubuntu-latest"
          - target: "x86_64-unknown-linux-musl"
            os: "ubuntu-latest"
    runs-on: ${{ matrix.os }}
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3

      - name: "Upload Binaries"
        uses: "taiki-e/upload-rust-binary-action@v1"
        with:
          bin: "shazam"
          target: ${{ matrix.target }}
          archive: $bin-${{ matrix.target }}
          ref: refs/tags/v${{ needs.get-tag.outputs.pkg-version }}
          token: ${{ secrets.GITHUB_TOKEN }}

  push-to-registry:
    name: "Push Docker image to Docker Hub"
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    needs:
      - "get-tag"
      - "upload-assets"
    runs-on: "ubuntu-latest"
    steps:
      - name: "Check out the repo"
        uses: actions/checkout@v3

      - name: "Log in to Docker Hub"
        uses: "docker/login-action@v2"
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      
      - name: "Extract metadata (tags, labels) for Docker"
        id: "meta"
        uses: "docker/metadata-action@v4"
        with:
          images: "bwks/shazam"
      
      - name: "Build and push Docker image"
        uses: "docker/build-push-action@v3"
        with:
          context: .
          push: true
          tags: bwks/shazam:latest,bwks/shazam:v${{ needs.get-tag.outputs.pkg-version }}
          labels: ${{ steps.meta.outputs.labels }}

Outro

My friends, we have come to the end of the road. In this post, I showed you how to create Github workflows to generate binary assets, and also generate a Docker container image for a Rust project. These assets are then uploaded as Github releases and Docker hub tags.