Agent skill

gha-docker-publish

ALWAYS invoke this skill before writing any GitHub Actions workflow that builds or pushes Docker images — it contains specific opinionated patterns that produce secure, reliable workflows and that you cannot reliably guess. The conventions it enforces include: (1) datetime+SHA image tags in YYYYMMDDHHMMSS_sha format for unambiguous build traceability, (2) a `workflow_dispatch` boolean `push` input that defaults to `false` (safe dry-run by default — a common mistake is using `type:choice` with `dry_run` inversion instead), (3) a `publish-docker` PR label gate that lets CI validate builds on PRs without pushing, (4) conditional `latest` tag using `enable={{is_default_branch}}` (never hardcoded `enable=true`), (5) GHA layer cache with `mode=max` for maximum rebuild speed, (6) no inline `${{ github.sha }}` in `run:` scripts (security hardening), (7) dual-registry login and push patterns when publishing to both GHCR and a private registry. Use this whenever the user asks to: set up GHCR publishing, add a docker-publish.yml workflow, add layer caching to Docker CI, configure datetime/SHA image tags, add a manual build-only dispatch trigger, push to multiple registries, or build Docker images on PRs.

Stars 0
Forks 0

Install this agent skill to your Project

npx add-skill https://github.com/yorch/claude-skills/tree/main/gha-docker-publish

SKILL.md

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

yaml
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)

yaml
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

yaml
${{ 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:

yaml
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

yaml
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:

yaml
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:

yaml
- 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

yaml
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:

yaml
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:

yaml
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.

Expand your agent's capabilities with these related and highly-rated skills.

yorch/claude-skills

typescript-unknown-jsx-expression

Fix TypeScript error "Type 'unknown' is not assignable to type ReactNode" when using Record<string, unknown> values directly in JSX && short-circuit expressions. Use when: (1) JSX expression {someObj.field && <Component />} fails with TS2322, (2) Reading from a JSON or untyped object via bracket notation in JSX conditionals, (3) Values typed as unknown appear in JSX even when wrapped in String() or guarded with truthiness checks. Fix: use !! to coerce to boolean before the && operator.

0 0
Explore
yorch/claude-skills

prisma-7-docker-migrations

Run Prisma 7 migrations inside a multi-stage Docker production image. Use when: (1) `prisma migrate deploy` crashes with "Cannot resolve environment variable: DATABASE_URL" or "cannot find module" at container startup, (2) building a production Docker image that needs to run migrations before starting the server, (3) you copied only node_modules/prisma + node_modules/@prisma but the Prisma 7 CLI still fails with MODULE_NOT_FOUND at runtime, (4) Yarn 4 node-modules linker .bin/prisma symlink breaks after Docker COPY. Covers prisma.config.ts (Prisma 7), full node_modules copy requirement, and symlink recreation pattern.

0 0
Explore
yorch/claude-skills

code-changes-review

Perform a comprehensive code review of current uncommitted changes in a git repository. Analyzes for bugs, security vulnerabilities, best practices, DRY violations, code smells, performance issues, and areas of improvement. Use when: review changes, code review, check my code, review diff, pre-commit review, PR review, quality check. Works with any language or framework.

0 0
Explore
yorch/claude-skills

rails-auto-assigned-field-validation

Fix for Rails models where validates :field, presence: true causes ALL creates to fail when the field is set by a before_create callback. Use when: (1) validates :number, presence: true is added to a model that uses before_create to auto-assign a sequence number, (2) model creates silently fail with "number can't be blank", (3) auto-generated fields (number, slug, token) fail presence validation despite being set in callbacks. Root cause: before_create runs AFTER validations. Fix: change before_create to before_validation on: :create.

0 0
Explore
yorch/claude-skills

app-docker-deploy-with-traefik

Generate Docker and Traefik deployment configurations for any application (Node.js, Python, Go, Rust, Java). Creates Dockerfile, docker-compose.yml, docker-compose.for-traefik.yml overlay, and .env.sample with production best practices. Use when: dockerize app, containerize, add Docker, deploy with Traefik, reverse proxy setup, HTTPS/SSL, Let's Encrypt certificates, production deployment, docker-compose setup. Requires: Docker, docker-compose.

0 0
Explore
mattpocock/skills

edit-article

Edit and improve articles by restructuring sections, improving clarity, and tightening prose. Use when user wants to edit, revise, or improve an article draft.

111,310 9,758
Explore

Didn't find tool you were looking for?

Be as detailed as possible for better results