Skip to content
Latchkey

Build and Push Docker Image workflow (talebook/talebook)

The Build and Push Docker Image workflow from talebook/talebook, explained and optimized by Latchkey.

C

CI health: C - fair

Point runs-on at Latchkey and get run de-duplication, job timeouts, SHA-pinned actions, self-healing for flaky steps, and up to 58% lower cost, applied automatically.

Grade your own workflow free or run it on Latchkey →
Source: talebook/talebook.github/workflows/build.ymlLicense BSD-2-ClauseView source

What it does

This is the Build and Push Docker Image workflow from the talebook/talebook repository, a real project running GitHub Actions. It is shown here with attribution under its BSD-2-Clause license.

Below, Latchkey shows a faster, safer version produced by its optimization engine.

The workflow

workflow (.yml)
name: Build and Push Docker Image

on:
  push:
    branches:
      - master
      - dev/*
      - claude/*
      - codex/*
    tags:
      - "v*"
  pull_request:
    branches:
      - master

env:
  REGISTRY: docker.io
  IMAGE_NAME: ${{ github.repository }}

permissions:
  contents: read

jobs:
  # ----------------------------------------
  # 计算构建矩阵与公共构建参数。
  # PR 只构 linux/amd64;push/tag 构建全部架构,每个架构一个独立 job。
  prepare:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.matrix.outputs.matrix }}
      build_args: ${{ steps.prep.outputs.build_args }}
      safe_ref_name: ${{ steps.prep.outputs.safe_ref_name }}
    steps:
      - name: Prepare build args and safe ref name
        id: prep
        run: |
          if [[ $GITHUB_REF == refs/tags/* ]]; then
            VERSION=${GITHUB_REF#refs/tags/}
          else
            VERSION=${GITHUB_SHA::8}
          fi
          SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}
          echo "build_args=GIT_VERSION=$VERSION" >> $GITHUB_OUTPUT
          echo "safe_ref_name=$SAFE_REF_NAME" >> $GITHUB_OUTPUT

      - name: Set build matrix
        id: matrix
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            echo 'matrix={"platform":["linux/amd64"]}' >> $GITHUB_OUTPUT
          else
            echo 'matrix={"platform":["linux/amd64","linux/arm64","linux/arm/v7"]}' >> $GITHUB_OUTPUT
          fi

  # ----------------------------------------
  # 每个平台单独一个 job 构建,按 digest 推送(不打 tag)。
  # 单平台构建保证同一 BuildKit 内不会并发跑多个 calibredb(见 issue #816)。
  build:
    needs: prepare
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    strategy:
      fail-fast: false
      matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4

      - name: Platform safe name
        id: pname
        run: echo "name=${platform//\//-}" >> $GITHUB_OUTPUT
        env:
          platform: ${{ matrix.platform }}

      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
        with:
          platforms: all

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Set build outputs
        id: out
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            echo "value=type=image,push=false" >> $GITHUB_OUTPUT
          else
            echo "value=type=image,name=${REGISTRY}/${IMAGE_NAME},push-by-digest=true,name-canonical=true,push=true" >> $GITHUB_OUTPUT
          fi
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}

      - name: Build SPA
        id: spa
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production-spa
          platforms: ${{ matrix.platform }}
          provenance: false
          outputs: ${{ steps.out.outputs.value }}
          build-args: ${{ needs.prepare.outputs.build_args }}
          cache-from: type=gha,scope=spa-${{ steps.pname.outputs.name }}
          cache-to: type=gha,scope=spa-${{ steps.pname.outputs.name }},mode=max

      - name: Build SSR
        id: ssr
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production-ssr
          platforms: ${{ matrix.platform }}
          provenance: false
          outputs: ${{ steps.out.outputs.value }}
          build-args: ${{ needs.prepare.outputs.build_args }}
          cache-from: type=gha,scope=ssr-${{ steps.pname.outputs.name }}
          cache-to: type=gha,scope=ssr-${{ steps.pname.outputs.name }},mode=max

      - name: Export digests
        if: github.event_name != 'pull_request'
        run: |
          mkdir -p /tmp/digests/spa /tmp/digests/ssr
          echo "${{ steps.spa.outputs.digest }}" > "/tmp/digests/spa/${{ steps.pname.outputs.name }}"
          echo "${{ steps.ssr.outputs.digest }}" > "/tmp/digests/ssr/${{ steps.pname.outputs.name }}"

      - name: Upload digests
        if: github.event_name != 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: digests-${{ steps.pname.outputs.name }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1

  # ----------------------------------------
  # 把各平台 digest 合并成 multi-arch manifest,并打上最终 tag。
  # PR 不推送,跳过本 job。
  merge:
    needs: [prepare, build]
    if: github.event_name != 'pull_request'
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - name: Download digests
        uses: actions/download-artifact@v4
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      # SPA 镜像:talebook/talebook:latest / <ver> / master / <branch>,
      # 以及 calibre-webserver:latest / master(仅 talebook/talebook 仓库)
      - name: Create SPA manifest
        run: |
          IMAGE="${REGISTRY}/${IMAGE_NAME}"
          TAGS=()
          if [[ $GITHUB_REF == refs/tags/* ]]; then
            TAGS+=("-t" "${IMAGE}:latest" "-t" "${IMAGE}:${GITHUB_REF_NAME}")
          fi
          if [[ "$GITHUB_REF" == "refs/heads/master" ]]; then
            TAGS+=("-t" "${IMAGE}:master")
          fi
          if [[ "$GITHUB_REF" == refs/heads/dev/* || "$GITHUB_REF" == refs/heads/claude/* || "$GITHUB_REF" == refs/heads/codex/* ]]; then
            TAGS+=("-t" "${IMAGE}:${SAFE_REF_NAME}")
          fi
          if [[ "$GITHUB_REPOSITORY" == "talebook/talebook" ]]; then
            if [[ $GITHUB_REF == refs/tags/* ]]; then
              TAGS+=("-t" "${REGISTRY}/talebook/calibre-webserver:latest")
            fi
            if [[ "$GITHUB_REF" == "refs/heads/master" ]]; then
              TAGS+=("-t" "${REGISTRY}/talebook/calibre-webserver:master")
            fi
          fi
          if [[ ${#TAGS[@]} -eq 0 ]]; then echo "No SPA tags for this ref, skip."; exit 0; fi
          SRCS=()
          for f in /tmp/digests/spa/*; do SRCS+=("${IMAGE}@$(cat "$f")"); done
          docker buildx imagetools create "${TAGS[@]}" "${SRCS[@]}"
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          SAFE_REF_NAME: ${{ needs.prepare.outputs.safe_ref_name }}

      # SSR 镜像:talebook/talebook:server-side-render / server-side-render-<ver|branch>
      - name: Create SSR manifest
        run: |
          IMAGE="${REGISTRY}/${IMAGE_NAME}"
          TAGS=()
          if [[ $GITHUB_REF == refs/tags/* ]]; then
            TAGS+=("-t" "${IMAGE}:server-side-render" "-t" "${IMAGE}:server-side-render-${GITHUB_REF_NAME}")
          fi
          if [[ "$GITHUB_REF" == "refs/heads/master" ]]; then
            TAGS+=("-t" "${IMAGE}:server-side-render")
          fi
          if [[ "$GITHUB_REF" == refs/heads/dev/* || "$GITHUB_REF" == refs/heads/claude/* || "$GITHUB_REF" == refs/heads/codex/* ]]; then
            TAGS+=("-t" "${IMAGE}:server-side-render-${SAFE_REF_NAME}")
          fi
          if [[ ${#TAGS[@]} -eq 0 ]]; then echo "No SSR tags for this ref, skip."; exit 0; fi
          SRCS=()
          for f in /tmp/digests/ssr/*; do SRCS+=("${IMAGE}@$(cat "$f")"); done
          docker buildx imagetools create "${TAGS[@]}" "${SRCS[@]}"
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          SAFE_REF_NAME: ${{ needs.prepare.outputs.safe_ref_name }}

The same workflow, on Latchkey

Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.

name: Build and Push Docker Image
 
on:
  push:
    branches:
      - master
      - dev/*
      - claude/*
      - codex/*
    tags:
      - "v*"
  pull_request:
    branches:
      - master
 
env:
  REGISTRY: docker.io
  IMAGE_NAME: ${{ github.repository }}
 
permissions:
  contents: read
 
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true
 
jobs:
  # ----------------------------------------
  # 计算构建矩阵与公共构建参数。
  # PR 只构 linux/amd64;push/tag 构建全部架构,每个架构一个独立 job。
  prepare:
    timeout-minutes: 30
    runs-on: latchkey-small
    outputs:
      matrix: ${{ steps.matrix.outputs.matrix }}
      build_args: ${{ steps.prep.outputs.build_args }}
      safe_ref_name: ${{ steps.prep.outputs.safe_ref_name }}
    steps:
      - name: Prepare build args and safe ref name
        id: prep
        run: |
          if [[ $GITHUB_REF == refs/tags/* ]]; then
            VERSION=${GITHUB_REF#refs/tags/}
          else
            VERSION=${GITHUB_SHA::8}
          fi
          SAFE_REF_NAME=${GITHUB_REF_NAME//\//-}
          echo "build_args=GIT_VERSION=$VERSION" >> $GITHUB_OUTPUT
          echo "safe_ref_name=$SAFE_REF_NAME" >> $GITHUB_OUTPUT
 
      - name: Set build matrix
        id: matrix
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            echo 'matrix={"platform":["linux/amd64"]}' >> $GITHUB_OUTPUT
          else
            echo 'matrix={"platform":["linux/amd64","linux/arm64","linux/arm/v7"]}' >> $GITHUB_OUTPUT
          fi
 
  # ----------------------------------------
  # 每个平台单独一个 job 构建,按 digest 推送(不打 tag)。
  # 单平台构建保证同一 BuildKit 内不会并发跑多个 calibredb(见 issue #816)。
  build:
    timeout-minutes: 30
    needs: prepare
    runs-on: latchkey-small
    permissions:
      contents: read
      packages: write
    strategy:
      fail-fast: false
      matrix: ${{ fromJSON(needs.prepare.outputs.matrix) }}
    steps:
      - name: Checkout
        uses: actions/checkout@v4
 
      - name: Platform safe name
        id: pname
        run: echo "name=${platform//\//-}" >> $GITHUB_OUTPUT
        env:
          platform: ${{ matrix.platform }}
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v3
        with:
          platforms: all
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
 
      - name: Set build outputs
        id: out
        run: |
          if [[ "${{ github.event_name }}" == "pull_request" ]]; then
            echo "value=type=image,push=false" >> $GITHUB_OUTPUT
          else
            echo "value=type=image,name=${REGISTRY}/${IMAGE_NAME},push-by-digest=true,name-canonical=true,push=true" >> $GITHUB_OUTPUT
          fi
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
 
      - name: Build SPA
        id: spa
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production-spa
          platforms: ${{ matrix.platform }}
          provenance: false
          outputs: ${{ steps.out.outputs.value }}
          build-args: ${{ needs.prepare.outputs.build_args }}
          cache-from: type=gha,scope=spa-${{ steps.pname.outputs.name }}
          cache-to: type=gha,scope=spa-${{ steps.pname.outputs.name }},mode=max
 
      - name: Build SSR
        id: ssr
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production-ssr
          platforms: ${{ matrix.platform }}
          provenance: false
          outputs: ${{ steps.out.outputs.value }}
          build-args: ${{ needs.prepare.outputs.build_args }}
          cache-from: type=gha,scope=ssr-${{ steps.pname.outputs.name }}
          cache-to: type=gha,scope=ssr-${{ steps.pname.outputs.name }},mode=max
 
      - name: Export digests
        if: github.event_name != 'pull_request'
        run: |
          mkdir -p /tmp/digests/spa /tmp/digests/ssr
          echo "${{ steps.spa.outputs.digest }}" > "/tmp/digests/spa/${{ steps.pname.outputs.name }}"
          echo "${{ steps.ssr.outputs.digest }}" > "/tmp/digests/ssr/${{ steps.pname.outputs.name }}"
 
      - name: Upload digests
        if: github.event_name != 'pull_request'
        uses: actions/upload-artifact@v4
        with:
          name: digests-${{ steps.pname.outputs.name }}
          path: /tmp/digests/*
          if-no-files-found: error
          retention-days: 1
 
  # ----------------------------------------
  # 把各平台 digest 合并成 multi-arch manifest,并打上最终 tag。
  # PR 不推送,跳过本 job。
  merge:
    timeout-minutes: 30
    needs: [prepare, build]
    if: github.event_name != 'pull_request'
    runs-on: latchkey-small
    permissions:
      contents: read
      packages: write
    steps:
      - name: Download digests
        uses: actions/download-artifact@v4
        with:
          path: /tmp/digests
          pattern: digests-*
          merge-multiple: true
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
 
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
 
      # SPA 镜像:talebook/talebook:latest / <ver> / master / <branch>,
      # 以及 calibre-webserver:latest / master(仅 talebook/talebook 仓库)
      - name: Create SPA manifest
        run: |
          IMAGE="${REGISTRY}/${IMAGE_NAME}"
          TAGS=()
          if [[ $GITHUB_REF == refs/tags/* ]]; then
            TAGS+=("-t" "${IMAGE}:latest" "-t" "${IMAGE}:${GITHUB_REF_NAME}")
          fi
          if [[ "$GITHUB_REF" == "refs/heads/master" ]]; then
            TAGS+=("-t" "${IMAGE}:master")
          fi
          if [[ "$GITHUB_REF" == refs/heads/dev/* || "$GITHUB_REF" == refs/heads/claude/* || "$GITHUB_REF" == refs/heads/codex/* ]]; then
            TAGS+=("-t" "${IMAGE}:${SAFE_REF_NAME}")
          fi
          if [[ "$GITHUB_REPOSITORY" == "talebook/talebook" ]]; then
            if [[ $GITHUB_REF == refs/tags/* ]]; then
              TAGS+=("-t" "${REGISTRY}/talebook/calibre-webserver:latest")
            fi
            if [[ "$GITHUB_REF" == "refs/heads/master" ]]; then
              TAGS+=("-t" "${REGISTRY}/talebook/calibre-webserver:master")
            fi
          fi
          if [[ ${#TAGS[@]} -eq 0 ]]; then echo "No SPA tags for this ref, skip."; exit 0; fi
          SRCS=()
          for f in /tmp/digests/spa/*; do SRCS+=("${IMAGE}@$(cat "$f")"); done
          docker buildx imagetools create "${TAGS[@]}" "${SRCS[@]}"
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          SAFE_REF_NAME: ${{ needs.prepare.outputs.safe_ref_name }}
 
      # SSR 镜像:talebook/talebook:server-side-render / server-side-render-<ver|branch>
      - name: Create SSR manifest
        run: |
          IMAGE="${REGISTRY}/${IMAGE_NAME}"
          TAGS=()
          if [[ $GITHUB_REF == refs/tags/* ]]; then
            TAGS+=("-t" "${IMAGE}:server-side-render" "-t" "${IMAGE}:server-side-render-${GITHUB_REF_NAME}")
          fi
          if [[ "$GITHUB_REF" == "refs/heads/master" ]]; then
            TAGS+=("-t" "${IMAGE}:server-side-render")
          fi
          if [[ "$GITHUB_REF" == refs/heads/dev/* || "$GITHUB_REF" == refs/heads/claude/* || "$GITHUB_REF" == refs/heads/codex/* ]]; then
            TAGS+=("-t" "${IMAGE}:server-side-render-${SAFE_REF_NAME}")
          fi
          if [[ ${#TAGS[@]} -eq 0 ]]; then echo "No SSR tags for this ref, skip."; exit 0; fi
          SRCS=()
          for f in /tmp/digests/ssr/*; do SRCS+=("${IMAGE}@$(cat "$f")"); done
          docker buildx imagetools create "${TAGS[@]}" "${SRCS[@]}"
        env:
          REGISTRY: ${{ env.REGISTRY }}
          IMAGE_NAME: ${{ env.IMAGE_NAME }}
          SAFE_REF_NAME: ${{ needs.prepare.outputs.safe_ref_name }}
 

What changed

4 third-party actions are referenced by a movable tag. Pin them to the commit SHA (Latchkey resolves and applies this automatically) so a repointed tag cannot change what runs.

What Latchkey heals here

This workflow has steps that commonly fail on transient issues (network, registries, flaky browsers). On Latchkey managed runners they are detected, retried, and self-healed instead of failing your build:

This workflow runs 3 jobs per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.

Actions used in this workflow