Agent Skills: GitHub Actions: Docker Build & Publish Workflow

|

UncategorizedID: yorch/claude-skills/gha-docker-publish

Install this agent skill to your local

pnpm dlx add-skill https://github.com/yorch/claude-skills/tree/HEAD/gha-docker-publish

Skill Files

Browse the full folder contents for gha-docker-publish.

Download Skill

Loading file tree…

gha-docker-publish/SKILL.md

Skill Metadata

Name
gha-docker-publish
Description
|

GitHub Actions: Docker Build & Publish Workflow

When to Use This Skill

Use this pattern when you need a CI workflow that:

  • Pushes Docker images to GHCR (and optionally a second registry)
  • Tags images with a datetime+sha slug for traceability and rollback
  • Applies latest only on the default branch (not feature branches or tags)
  • Supports manual triggering with a push toggle (useful for dry-run builds)
  • Uses GHA layer cache to speed up repeat builds
  • Validates Docker builds on pull requests (every PR) with optional push to GHCR via publish-docker label

The Complete Workflow

name: Build and Publish Docker Image

on:
  push:
    branches:
      - main
    tags:
      - "v*"
  pull_request:
    types: [opened, synchronize, reopened, labeled]
  workflow_dispatch:
    inputs:
      push:
        description: "Push image to registries"
        required: false
        default: false
        type: boolean

env:
  GHCR_IMAGE: ghcr.io/${{ github.repository }}

permissions:
  contents: read
  packages: write

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Checkout
        uses: actions/checkout@v4

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

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Log in to external registry
        if: vars.EXTERNAL_REGISTRY_URL != ''
        uses: docker/login-action@v3
        with:
          registry: ${{ vars.EXTERNAL_REGISTRY_URL }}
          username: ${{ secrets.EXTERNAL_REGISTRY_USERNAME }}
          password: ${{ secrets.EXTERNAL_REGISTRY_PASSWORD }}

      - name: Get commit info
        id: commit
        run: |
          echo "datetime=$(git log -1 --format=%cd --date=format:'%Y%m%d%H%M' HEAD)" >> ${GITHUB_OUTPUT}
          echo "sha=$(git rev-parse --short HEAD)" >> ${GITHUB_OUTPUT}

      - name: Build image list
        id: images
        env:
          GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
          EXTERNAL_REGISTRY_URL: ${{ vars.EXTERNAL_REGISTRY_URL }}
          EXTERNAL_REGISTRY_IMAGE: ${{ vars.EXTERNAL_REGISTRY_IMAGE }}
        run: |
          IMAGES="${GHCR_IMAGE}"
          if [[ -n "${EXTERNAL_REGISTRY_URL}" ]]; then
            IMAGE_NAME="${EXTERNAL_REGISTRY_IMAGE:-my-app}"
            IMAGES="${IMAGES}"$'\n'"${EXTERNAL_REGISTRY_URL}/${IMAGE_NAME}"
          fi
          {
            echo 'list<<EOF'
            echo "${IMAGES}"
            echo 'EOF'
          } >> "${GITHUB_OUTPUT}"

      - name: Extract Docker metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ steps.images.outputs.list }}
          tags: |
            type=ref,event=pr
            type=raw,value=${{ steps.commit.outputs.datetime }}_${{ steps.commit.outputs.sha }},enable=${{ github.event_name != 'pull_request' }}
            type=ref,event=branch
            type=ref,event=tag
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v6
        with:
          context: .
          push: >-
            ${{
              github.event_name == 'push' ||
              (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'publish-docker')) ||
              (github.event_name == 'workflow_dispatch' && inputs.push)
            }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          platforms: linux/amd64

Key Design Decisions

Tags Generated

| Tag pattern | Example | When applied | |---|---|---| | pr-N | pr-42 | PR events only — human-readable, identifies the PR | | datetime_sha | 202603151430_a1b2c3d | push and tag events — chronological + traceable | | Branch name | main | branch push events (not tag push events) — human-readable current ref | | latest | latest | Only on the default branch (main) | | Tag ref | v1.2.3 | Only when pushing a v* git tag |

The datetime_sha format is important: it lets you sort images chronologically in the registry UI and trace back to an exact commit without needing semver versioning.

Why metadata-action for latest instead of shell conditionals?

docker/metadata-action handles the enable={{is_default_branch}} expression natively — latest is emitted only when github.ref matches the default branch.

The naive alternatives both fail:

|| '' (blank line in tags block)

tags: |
  ghcr.io/org/app:${{ steps.tag.outputs.version }}
  ${{ github.event_name == 'push' && 'ghcr.io/org/app:latest' || '' }}

A blank line is passed as an empty tag reference, causing invalid reference format errors in docker/build-push-action (version-dependent but unreliable).

|| null

${{ github.event_name == 'push' && 'ghcr.io/org/app:latest' || null }}

GHA expressions have no true null type. Depending on context, null coerces to the literal string "null", which the action attempts to push as a tag named null.

metadata-action avoids both pitfalls — the enable= option suppresses a tag entry entirely, producing no output line at all.

PR builds: build always, push only with publish-docker label

Every pull_request event (opened, pushed to, reopened, labeled) triggers the workflow and builds the Docker image. The build serves as a validation check — it fails fast if the Dockerfile is broken, even without pushing anything.

The image is only pushed when the publish-docker label is present on the PR at the time the run starts. On PR builds, type=ref,event=branch does not fire — docker/metadata-action filters tags by event type, so event=branch only activates on push events to branches. PR builds emit only the pr-N tag (plus no datetime_sha, which is suppressed by its enable= guard).

This is checked via:

contains(github.event.pull_request.labels.*.name, 'publish-docker')

This expression reads the label set from the webhook payload at run time. If the label was already on the PR before a new commit was pushed, that commit's build also pushes.

Fork PR limitation: On pull_request events from forked repositories, GitHub restricts GITHUB_TOKEN to read-only regardless of the permissions: declaration. The publish-docker label check passes but the GHCR push fails with a permission error. This is expected GHA behavior. The publish-on-label feature works only for PRs from branches within the same repository. If the repository is public, document this restriction for contributors so they know why labeling a fork PR will not push.

Dual-registry support (optional)

The external registry is entirely opt-in via repository variables and secrets:

  • vars.EXTERNAL_REGISTRY_URL — e.g., registry.example.com or docker.io
  • vars.EXTERNAL_REGISTRY_IMAGE — image name on that registry (defaults to my-app)
  • secrets.EXTERNAL_REGISTRY_USERNAME / EXTERNAL_REGISTRY_PASSWORD

If EXTERNAL_REGISTRY_URL is not set, the login step is skipped and only GHCR is used.

GHA cache with mode=max

cache-from: type=gha
cache-to: type=gha,mode=max

mode=max caches all intermediate layers, not just the final image. This is more storage-intensive but dramatically speeds up builds when dependencies or base images haven't changed.

workflow_dispatch dry-run

The push expression in the Build and push step covers three event types:

push: >-
  ${{
    github.event_name == 'push' ||
    (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'publish-docker')) ||
    (github.event_name == 'workflow_dispatch' && inputs.push)
  }}
  • On push / tag events: always pushes
  • On pull_request events: pushes only when the publish-docker label is present (see the PR builds section above)
  • On manual dispatch: push defaults to false (safe default — won't accidentally publish on a click). Set to true explicitly to push.

Security: env: block for context variables in run: scripts

GitHub's security hardening guide warns against interpolating ${{ context }} values directly inside run: shell scripts. Even seemingly safe values like github.sha can be used for script injection if an attacker controls a value that ends up in the context.

The Build image list step follows this pattern — all GHA context values are passed through the env: block, never interpolated directly into the shell:

- name: Build image list
  env:
    GHCR_IMAGE: ${{ env.GHCR_IMAGE }}
    EXTERNAL_REGISTRY_URL: ${{ vars.EXTERNAL_REGISTRY_URL }}
    EXTERNAL_REGISTRY_IMAGE: ${{ vars.EXTERNAL_REGISTRY_IMAGE }}
  run: |
    IMAGES="${GHCR_IMAGE}"   # safe: reads from env var, not ${{ }}
    ...

The Get commit info step uses only git commands with no GHA context variables, so no env: block is needed there.

Permissions

permissions:
  contents: read
  packages: write

packages: write is required to push to GHCR using GITHUB_TOKEN. Always scope permissions to the minimum needed.


Setup Checklist

  • [ ] Place file at .github/workflows/docker-publish.yml
  • [ ] Replace my-app in the Build image list step with your actual image name (or set EXTERNAL_REGISTRY_IMAGE variable)
  • [ ] Ensure a Dockerfile exists at the repo root (or set context: appropriately)
  • [ ] To enable image push on PRs, create a publish-docker label in the GitHub repository (Settings → Labels) — PRs without this label build but do not push
  • [ ] If using an external registry, add these to the GitHub repo settings:
    • Variable: EXTERNAL_REGISTRY_URL
    • Variable: EXTERNAL_REGISTRY_IMAGE (optional, defaults to my-app)
    • Secret: EXTERNAL_REGISTRY_USERNAME
    • Secret: EXTERNAL_REGISTRY_PASSWORD
  • [ ] GHCR authentication uses GITHUB_TOKEN automatically — no extra secrets needed

Customization Points

Multi-platform builds — replace platforms: linux/amd64 with:

platforms: linux/amd64,linux/arm64

Note: multi-platform builds cannot use GHA cache in mode=max for all layers; consider type=registry cache instead.

Build args — add to the Build and push step:

build-args: |
  APP_VERSION=${{ steps.commit.outputs.datetime }}_${{ steps.commit.outputs.sha }}

Different default branchenable={{is_default_branch}} reads from the repository's actual default branch setting, so renaming mainmaster or anything else requires no change.

Timeouttimeout-minutes: 30 is a reasonable default. Adjust based on your build time.