From brnby
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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/brnby:gha-docker-publishThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Use this pattern when you need a CI workflow that:
Use this pattern when you need a CI workflow that:
latest only on the default branch (not feature branches or tags)publish-docker labelname: 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
| 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.
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.
publish-docker labelEvery 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.
The external registry is entirely opt-in via repository variables and secrets:
vars.EXTERNAL_REGISTRY_URL — e.g., registry.example.com or docker.iovars.EXTERNAL_REGISTRY_IMAGE — image name on that registry (defaults to my-app)secrets.EXTERNAL_REGISTRY_USERNAME / EXTERNAL_REGISTRY_PASSWORDIf EXTERNAL_REGISTRY_URL is not set, the login step is skipped and only GHCR is used.
mode=maxcache-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-runThe 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)
}}
push / tag events: always pushespull_request events: pushes only when the publish-docker label is present (see the PR builds section above)push defaults to false (safe default — won't accidentally publish on a click). Set to true explicitly to push.env: block for context variables in run: scriptsGitHub'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:
contents: read
packages: write
packages: write is required to push to GHCR using GITHUB_TOKEN. Always scope permissions to the minimum needed.
.github/workflows/docker-publish.ymlmy-app in the Build image list step with your actual image name (or set EXTERNAL_REGISTRY_IMAGE variable)Dockerfile exists at the repo root (or set context: appropriately)publish-docker label in the GitHub
repository (Settings → Labels) — PRs without this label build but do not pushEXTERNAL_REGISTRY_URLEXTERNAL_REGISTRY_IMAGE (optional, defaults to my-app)EXTERNAL_REGISTRY_USERNAMEEXTERNAL_REGISTRY_PASSWORDGITHUB_TOKEN automatically — no extra secrets neededMulti-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.
npx claudepluginhub yorch/claude-skills --plugin brnbyProvides behavioral guidelines to reduce common LLM coding mistakes, focusing on simplicity, surgical changes, assumption surfacing, and verifiable success criteria.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.