Build Images (Manual: Release or Preflight) #6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
name: "Build Images (Manual: Release or Preflight)" | |
on: | |
workflow_dispatch: | |
inputs: | |
ref: | |
description: "Git ref to build (tag vX.Y.Z, branch, or SHA)" | |
type: string | |
required: true | |
default: main | |
push_images: | |
description: "Push images to GHCR" | |
type: boolean | |
required: true | |
default: true | |
publish_latest: | |
description: "Also tag ':latest' (release only)" | |
type: boolean | |
required: true | |
default: true | |
permissions: | |
contents: read | |
packages: write | |
env: | |
REGISTRY: ghcr.io | |
jobs: | |
build: | |
concurrency: | |
group: build-images-${{ inputs.ref }}-${{ matrix.service }} | |
cancel-in-progress: true | |
runs-on: ubuntu-22.04 | |
strategy: | |
matrix: | |
service: [backend, frontend, workers] | |
steps: | |
- name: Validate ref exists | |
shell: bash | |
run: | | |
set -euo pipefail | |
REPO="https://github.com/${{ github.repository }}" | |
REF="${{ inputs.ref }}" | |
git ls-remote --exit-code "$REPO" "$REF" >/dev/null 2>&1 || \ | |
git ls-remote --exit-code "$REPO" "refs/heads/$REF" >/dev/null 2>&1 || \ | |
git ls-remote --exit-code "$REPO" "refs/tags/$REF" >/dev/null 2>&1 || { | |
echo "❌ Error: ref '$REF' does not exist in repository" | |
exit 1 | |
} | |
echo "✅ Validated ref '$REF' exists" | |
- name: Checkout ref | |
uses: actions/checkout@v4 | |
with: | |
ref: ${{ inputs.ref }} | |
fetch-depth: 0 | |
- name: Source version.sh | |
id: vsh | |
shell: bash | |
run: | | |
set -euo pipefail | |
source ./scripts/version.sh | |
{ | |
echo "semver=$SEMVER" | |
echo "major=$MAJOR" | |
echo "minor=$MINOR" | |
echo "full=$FULL_VERSION" | |
echo "docker_tag=$DOCKER_TAG" | |
echo "commits_since_tag=$COMMITS_SINCE_TAG" | |
echo "hash=$GIT_HASH" | |
echo "short_sha=$SHORT_SHA" | |
echo "ts=$BUILD_TIMESTAMP" | |
echo "release_mode=$RELEASE_MODE" | |
echo "channel=$CHANNEL_SAFE" | |
echo "channel_tag=$CHANNEL_TAG_SAFE" | |
echo "git_dirty=$GIT_DIRTY" | |
} >> "$GITHUB_OUTPUT" | |
echo "Version: semver=$SEMVER full=$FULL_VERSION release=$RELEASE_MODE commits=$COMMITS_SINCE_TAG dirty=$GIT_DIRTY" | |
- name: Sanity check semver matches input (release only) | |
shell: bash | |
run: | | |
set -euo pipefail | |
REF="${{ inputs.ref }}" | |
# Only validate if input looks like vX.Y.Z | |
if [[ "$REF" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then | |
expected="${REF#v}" # strip 'v' prefix | |
actual="${{ steps.vsh.outputs.semver }}" | |
if [[ "$actual" != "$expected" ]]; then | |
echo "❌ Sanity check failed: input ref '$REF' but version.sh computed SEMVER='$actual'" | |
exit 1 | |
fi | |
echo "✅ Sanity check passed: SEMVER=$actual matches input ref $REF" | |
else | |
echo "ℹ️ Skipping sanity check (input '$REF' is not a semver tag)" | |
fi | |
- name: Login to GHCR | |
if: inputs.push_images | |
uses: docker/login-action@v3 | |
with: | |
registry: ${{ env.REGISTRY }} | |
username: ${{ github.actor }} | |
password: ${{ secrets.GITHUB_TOKEN }} | |
- name: Fail if immutable tags already exist (release only) | |
if: inputs.push_images && steps.vsh.outputs.release_mode == 'true' | |
shell: bash | |
env: | |
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
run: | | |
set -euo pipefail | |
owner='${{ github.repository_owner }}' | |
sem='${{ steps.vsh.outputs.semver }}' | |
check_tag() { | |
local svc="$1" tag="$2" | |
# Try org; if 404, try user; use --paginate to avoid missing older tags | |
set +e | |
existing=$(gh api -H "Accept: application/vnd.github+json" \ | |
--paginate "/orgs/$owner/packages/container/eclaire-${svc}/versions" 2>/dev/null \ | |
| jq -r '.[].metadata.container.tags[]' 2>/dev/null \ | |
| grep -E "^${tag}$") | |
if [[ -z "$existing" ]]; then | |
existing=$(gh api -H "Accept: application/vnd.github+json" \ | |
--paginate "/users/$owner/packages/container/eclaire-${svc}/versions" 2>/dev/null \ | |
| jq -r '.[].metadata.container.tags[]' 2>/dev/null \ | |
| grep -E "^${tag}$") | |
fi | |
set -e | |
[[ -n "$existing" ]] | |
} | |
for svc in backend frontend workers; do | |
if check_tag "$svc" "$sem"; then | |
echo "❌ Refusing to overwrite eclaire-${svc}:${sem} in GHCR." | |
exit 2 | |
fi | |
done | |
- name: Set up QEMU | |
uses: docker/setup-qemu-action@v3 | |
- name: Set up Buildx | |
uses: docker/setup-buildx-action@v3 | |
- name: Lowercase owner | |
id: who | |
shell: bash | |
run: | | |
set -euo pipefail | |
echo "lc=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT" | |
- name: Docker metadata | |
id: meta | |
uses: docker/metadata-action@v5 | |
with: | |
images: ${{ env.REGISTRY }}/${{ steps.who.outputs.lc }}/eclaire-${{ matrix.service }} | |
tags: | | |
# Release: X.Y.Z, X.Y, latest, sha-<7> | |
type=raw,value=${{ steps.vsh.outputs.semver }},enable=${{ steps.vsh.outputs.release_mode == 'true' }} | |
type=raw,value=${{ steps.vsh.outputs.major }}.${{ steps.vsh.outputs.minor }},enable=${{ steps.vsh.outputs.release_mode == 'true' }} | |
type=raw,value=latest,enable=${{ steps.vsh.outputs.release_mode == 'true' && inputs.publish_latest }} | |
type=raw,value=sha-${{ steps.vsh.outputs.short_sha }} | |
# Non-release: branch name and detailed tag | |
type=raw,value=${{ steps.vsh.outputs.channel_tag }},enable=${{ steps.vsh.outputs.release_mode != 'true' }} | |
type=raw,value=${{ steps.vsh.outputs.semver }}-${{ steps.vsh.outputs.channel_tag }}-sha-${{ steps.vsh.outputs.short_sha }},enable=${{ steps.vsh.outputs.release_mode != 'true' }} | |
labels: | | |
org.opencontainers.image.title=Eclaire-${{ matrix.service }} | |
org.opencontainers.image.version=${{ steps.vsh.outputs.full }} | |
org.opencontainers.image.revision=${{ steps.vsh.outputs.hash }} | |
org.opencontainers.image.url=https://eclaire.co | |
org.opencontainers.image.documentation=https://eclaire.co/docs/ | |
org.opencontainers.image.authors=Eclaire Labs <https://github.com/eclaire-labs/> | |
co.eclaire.origin=ci | |
co.eclaire.channel=${{ steps.vsh.outputs.channel }} | |
co.eclaire.channel_tag=${{ steps.vsh.outputs.channel_tag }} | |
co.eclaire.git_dirty=${{ steps.vsh.outputs.git_dirty }} | |
co.eclaire.ci_run_id=${{ github.run_id }} | |
co.eclaire.ci_run_number=${{ github.run_number }} | |
- name: Build & (optionally) Push ${{ matrix.service }} | |
uses: docker/build-push-action@v6 | |
with: | |
context: ./apps/${{ matrix.service }} | |
file: ./apps/${{ matrix.service }}/Dockerfile | |
push: ${{ inputs.push_images }} | |
platforms: linux/amd64,linux/arm64 | |
tags: ${{ steps.meta.outputs.tags }} | |
labels: ${{ steps.meta.outputs.labels }} | |
build-args: | | |
APP_VERSION=${{ steps.vsh.outputs.semver }} | |
APP_FULL_VERSION=${{ steps.vsh.outputs.full }} | |
APP_COMMITS_SINCE_TAG=${{ steps.vsh.outputs.commits_since_tag }} | |
APP_BUILD_TIMESTAMP=${{ steps.vsh.outputs.ts }} | |
APP_GIT_HASH=${{ steps.vsh.outputs.hash }} | |
APP_SERVICE=${{ matrix.service }} | |
APP_ORIGIN=ci | |
APP_CHANNEL=${{ steps.vsh.outputs.channel }} | |
APP_CHANNEL_TAG=${{ steps.vsh.outputs.channel_tag }} | |
APP_GIT_DIRTY=${{ steps.vsh.outputs.git_dirty }} | |
APP_CI_RUN_ID=${{ github.run_id }} | |
APP_CI_RUN_NUMBER=${{ github.run_number }} | |
cache-from: type=gha | |
cache-to: type=gha,mode=max |