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.
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
latestonly 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-dockerlabel
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.comordocker.iovars.EXTERNAL_REGISTRY_IMAGE— image name on that registry (defaults tomy-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_requestevents: pushes only when thepublish-dockerlabel is present (see the PR builds section above) - On manual dispatch:
pushdefaults tofalse(safe default — won't accidentally publish on a click). Set totrueexplicitly 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-appin theBuild image liststep with your actual image name (or setEXTERNAL_REGISTRY_IMAGEvariable) - Ensure a
Dockerfileexists at the repo root (or setcontext:appropriately) - To enable image push on PRs, create a
publish-dockerlabel 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 tomy-app) - Secret:
EXTERNAL_REGISTRY_USERNAME - Secret:
EXTERNAL_REGISTRY_PASSWORD
- Variable:
- GHCR authentication uses
GITHUB_TOKENautomatically — 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 branch — enable={{is_default_branch}} reads from the repository's actual default branch setting, so renaming main → master or anything else requires no change.
Timeout — timeout-minutes: 30 is a reasonable default. Adjust based on your build time.
Recommended Agent Skills
Expand your agent's capabilities with these related and highly-rated 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.
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.
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.
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.
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.
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.
Didn't find tool you were looking for?