Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 69 additions & 4 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ on:
description: 'GHCR organization name (defaults to repository owner)'
type: string
default: ''
on_existing_tag:
description: 'Behaviour when the target image tag already exists in an enabled registry (checked before build): "fail" (abort early, default), "skip" (skip build/push, still emit GitOps artifacts for an idempotent re-run), or "warn" (warn and build anyway).'
type: string
default: 'fail'
dockerfile_name:
description: 'Name of the Dockerfile'
type: string
Expand Down Expand Up @@ -311,7 +315,67 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Extracted version: $VERSION from tag: $TAG"

- name: Pre-flight tag existence check
id: preflight
env:
ON_EXISTING_TAG: ${{ inputs.on_existing_tag }}
ENABLE_DOCKERHUB: ${{ inputs.enable_dockerhub }}
ENABLE_GHCR: ${{ inputs.enable_ghcr }}
DOCKERHUB_ORG: ${{ inputs.dockerhub_org }}
GHCR_ORG: ${{ inputs.ghcr_org || steps.normalize.outputs.owner_lower }}
APP_NAME: ${{ matrix.app.name }}
VERSION: ${{ steps.version.outputs.version }}
run: |
set -uo pipefail

MODE="${ON_EXISTING_TAG:-fail}"
case "$MODE" in
fail|skip|warn) ;;
*) echo "::error::Invalid on_existing_tag '$MODE' (must be: fail, skip, warn)"; exit 1 ;;
esac

# Image tag matches what docker/metadata-action publishes (semver without leading 'v').
TAG="${VERSION#v}"
EXISTING=""

# `docker manifest inspect` reuses the registry logins from earlier steps and
# returns non-zero when the tag is absent. A non-existence/error result is
# treated as "not present" so a flaky check never blocks a legitimate build.
if [ "$ENABLE_DOCKERHUB" = "true" ]; then
REF="${DOCKERHUB_ORG}/${APP_NAME}:${TAG}"
if docker manifest inspect "$REF" >/dev/null 2>&1; then
EXISTING="${EXISTING}${EXISTING:+, }docker.io/${REF}"
fi
fi
if [ "$ENABLE_GHCR" = "true" ]; then
REF="ghcr.io/${GHCR_ORG}/${APP_NAME}:${TAG}"
if docker manifest inspect "$REF" >/dev/null 2>&1; then
EXISTING="${EXISTING}${EXISTING:+, }${REF}"
fi
fi

SHOULD_BUILD=true
if [ -n "$EXISTING" ]; then
case "$MODE" in
fail)
echo "::error::Tag ${TAG} already exists (${EXISTING}). Registry tags are immutable; aborting before rebuild. Use on_existing_tag: skip for idempotent re-runs."
exit 1
;;
skip)
echo "::warning::Tag ${TAG} already exists (${EXISTING}) — skipping build/push (idempotent re-run)."
SHOULD_BUILD=false
;;
warn)
echo "::warning::Tag ${TAG} already exists (${EXISTING}) — building anyway (push may fail on immutable registries)."
;;
esac
else
echo "Tag ${TAG} not present in enabled registries — proceeding with build."
fi
echo "should_build=$SHOULD_BUILD" >> "$GITHUB_OUTPUT"

- name: Docker metadata
if: steps.preflight.outputs.should_build != 'false'
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6
with:
Expand All @@ -320,6 +384,7 @@ jobs:
type=semver,pattern={{version}},value=${{ steps.version.outputs.version }}

- name: Build and push Docker image
if: steps.preflight.outputs.should_build != 'false'
id: build-push
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7
with:
Expand All @@ -339,7 +404,7 @@ jobs:

# ----------------- Cosign Image Signing -----------------
- name: Build cosign image references
if: inputs.enable_cosign_sign
if: inputs.enable_cosign_sign && steps.preflight.outputs.should_build != 'false'
id: cosign-refs
env:
DIGEST: ${{ steps.build-push.outputs.digest }}
Expand Down Expand Up @@ -367,17 +432,17 @@ jobs:
} >> "$GITHUB_OUTPUT"

- name: Sign container images with cosign
if: inputs.enable_cosign_sign
if: inputs.enable_cosign_sign && steps.preflight.outputs.should_build != 'false'
id: cosign-sign
continue-on-error: ${{ inputs.continue_gitops_on_signing_failure }}
continue-on-error: ${{ inputs.continue_gitops_on_signing_failure || false }}
uses: LerianStudio/github-actions-shared-workflows/src/security/cosign-sign@v1
with:
image-refs: ${{ steps.cosign-refs.outputs.refs }}
max-attempts: ${{ inputs.cosign_max_attempts }}
initial-delay: ${{ inputs.cosign_initial_delay }}

- name: Report cosign signing status
if: inputs.enable_cosign_sign && steps.cosign-sign.outcome == 'failure'
if: inputs.enable_cosign_sign && steps.preflight.outputs.should_build != 'false' && steps.cosign-sign.outcome == 'failure'
env:
REFS: ${{ steps.cosign-refs.outputs.refs }}
DIGEST: ${{ steps.build-push.outputs.digest }}
Expand Down
11 changes: 11 additions & 0 deletions docs/build-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ jobs:
| `enable_ghcr` | boolean | `true` | Enable pushing to GitHub Container Registry (requires `MANAGE_TOKEN`) |
| `dockerhub_org` | string | `lerianstudio` | DockerHub organization name |
| `ghcr_org` | string | `''` | GHCR organization (defaults to repository owner) |
| `on_existing_tag` | string | `fail` | Behaviour when the target tag already exists in an enabled registry (pre-flight check before build): `fail` (abort early), `skip` (skip build/push, still emit GitOps artifacts for an idempotent re-run), `warn` (warn and build anyway) |
| `dockerfile_name` | string | `Dockerfile` | Name of the Dockerfile |
| `app_name_prefix` | string | `''` | Prefix for app names in monorepo |
| `build_context` | string | `.` | Docker build context |
Expand All @@ -123,6 +124,16 @@ Uses `secrets: inherit` pattern. Required secrets:
| `MANAGE_TOKEN` | GitHub token for GHCR | `enable_ghcr: true` |
| `SLACK_WEBHOOK_URL` | Slack webhook for notifications | Optional |

## Tag Immutability and Re-runs

Before building, the workflow checks whether the target image tag already exists in each enabled registry (via `docker manifest inspect`, reusing the registry logins). This avoids a full rebuild that would only fail at push time on registries with tag immutability enabled. Behaviour is controlled by `on_existing_tag`:

- **`fail`** (default): abort early with a clear error instead of rebuilding then failing at push.
- **`skip`**: skip the build/push but still emit the GitOps tag artifacts (from the version), so a re-run remains idempotent for the downstream GitOps update.
- **`warn`**: emit a warning and build anyway (push may still fail on immutable registries).

A non-existent tag (or a check that errors out, e.g. transient registry issues) is treated as "not present" so the check never blocks a legitimate build.

## Platform Build Strategy

The workflow automatically selects platforms based on the tag type:
Expand Down