penpot/.github/workflows/build-docker.yml
David Barragán Merino f285b2dff7 👷 Add actionlint and zizmor checks for workflows
Signed-off-by: David Barragán Merino <david.barragan@kaleidos.net>
2026-09-30 10:31:02 +02:00

393 lines
16 KiB
YAML

name: Docker Images Builder
on:
workflow_dispatch:
inputs:
gh_ref:
description: 'Name of the branch or ref'
type: string
required: true
default: 'develop'
force:
description: 'Rebuild and overwrite even if this sha is already promoted'
type: boolean
required: false
default: false
workflow_call:
inputs:
gh_ref:
description: 'Name of the branch or ref'
type: string
required: true
sha:
description: >-
Exact commit to check out (full 40-char SHA, e.g. from
build-bundle.yml). Falls back to gh_ref when empty; gh_ref
always still names the branch tag to move.
type: string
required: false
default: ''
force:
description: 'Rebuild and overwrite even if this sha is already promoted'
type: boolean
required: false
default: false
# Literal group name: under `workflow_call`, `github.workflow` resolves to the
# caller's workflow, which put this workflow and the other reusable one called
# by the same caller into a single shared group, and left a manual dispatch of
# the same ref in a group of its own, free to race on the same artifacts.
concurrency:
group: build-docker-${{ inputs.gh_ref }}
cancel-in-progress: true
env:
ALL_IMAGES: backend frontend exporter storybook mcp
# All runner instances live on the same server, so the bundle is
# downloaded from S3 once and shared between build jobs through this
# host-local directory. Each build job falls back to S3 if the file is
# missing (e.g. if runners ever move to separate machines).
BUNDLE_CACHE: /var/tmp/penpot-bundle-cache
jobs:
# ── 1. Resolve the build key and check the whole set at once ───────────
prepare:
name: Prepare
runs-on: penpot-standar-runner
timeout-minutes: 15
outputs:
gh_ref: ${{ steps.vars.outputs.gh_ref }}
bundle_version: ${{ steps.vars.outputs.bundle_version }}
sha: ${{ steps.vars.outputs.sha }}
short_sha: ${{ steps.vars.outputs.short_sha }}
commit_title: ${{ steps.vars.outputs.commit_title }}
exists: ${{ steps.check.outputs.exists }}
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }}
- name: Extract some useful variables
id: vars
env:
GH_REF: ${{ inputs.gh_ref || github.ref_name }}
run: |
echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT
echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
echo "short_sha=$(git rev-parse --short=12 HEAD)" >> $GITHUB_OUTPUT
echo "commit_title=$(git log -1 --pretty=%s)" >> $GITHUB_OUTPUT
echo "bundle_version=$(git describe --tags --always)" >> $GITHUB_OUTPUT
# The image set is a single block, so a single set-level check is
# enough: `promote` drops a marker object in S3 only after every
# image was built AND every branch tag was moved. Marker present
# means there is nothing at all to do for this commit. `force`
# bypasses this check entirely.
- name: Check if this image set is already built
id: check
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
FORCE: ${{ inputs.force }}
SHORT_SHA: ${{ steps.vars.outputs.short_sha }}
run: |
if [ "$FORCE" = "true" ]; then
echo "exists=false" >> $GITHUB_OUTPUT
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip"
aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
{
echo "### 🔁 Image set build forced"
echo ""
echo "\`force: true\` — skipping the S3 marker check."
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if aws s3api head-object \
--bucket "$S3_BUCKET" \
--key "markers/images-sha-${SHORT_SHA}" \
> /dev/null 2>&1; then
echo "exists=true" >> $GITHUB_OUTPUT
{
echo "### ⏭️ Image set build skipped"
echo ""
echo "The whole set was already built and promoted for \`sha-${SHORT_SHA}\`."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "exists=false" >> $GITHUB_OUTPUT
# Stage the bundle in the host-local cache, once, for all the
# build jobs. Download to a temp name and mv for atomicity;
# prune stale bundles while at it.
mkdir -p "$BUNDLE_CACHE"
find "$BUNDLE_CACHE" -type f -mtime +1 -delete || true
ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip"
if [ ! -f "$ZIP" ]; then
aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
fi
fi
# ── 2. One build per image, in parallel, only when needed ──────────────
build:
name: Build ${{ matrix.image }}
runs-on: penpot-standar-runner
timeout-minutes: 60
needs: prepare
if: needs.prepare.outputs.exists == 'false'
strategy:
fail-fast: true
# 4 runner slots are available for build jobs on this server; cap the
# matrix at 3 so short jobs (prepare and other workflows' checks)
# never queue behind long builds.
max-parallel: 3
matrix:
image: [backend, frontend, exporter, storybook, mcp]
steps:
- name: Set common environment variables
run: |
# Each job execution will use its own docker configuration.
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}-${{ matrix.image }}" >> $GITHUB_ENV
- name: Checkout code
uses: actions/checkout@v6
with:
ref: ${{ inputs.sha != '' && inputs.sha || inputs.gh_ref }}
- name: Login to Docker Registry
uses: docker/login-action@v4
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# To avoid the "429 Too Many Requests" error when downloading
# images from DockerHub for unregistered users.
# https://docs.docker.com/docker-hub/usage/
- name: Login to DockerHub Registry
uses: docker/login-action@v4
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
# Images now build FROM Docker Hardened Images (dhi.io). DHI
# is free (Apache 2.0, no subscription), but pulling from it
# still requires an authenticated login -- a separate `docker
# login` against a different registry host, even though it
# reuses the same PUB_DOCKER_* credentials as the DockerHub
# login above.
- name: Login to Docker Hardened Images registry (base image pull)
uses: docker/login-action@v4
with:
registry: dhi.io
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
# Bundle staged once by `prepare` on this host; the S3 fallback only
# triggers if the cache is unavailable (runners on another machine,
# cache pruned mid-run, ...).
- name: Prepare Penpot bundle
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
IMAGE: ${{ matrix.image }}
run: |
ZIP="$BUNDLE_CACHE/penpot-sha-${SHORT_SHA}.zip"
if [ ! -f "$ZIP" ]; then
echo "Bundle not found in host cache; falling back to S3."
mkdir -p "$BUNDLE_CACHE"
aws s3 cp "s3://${S3_BUCKET}/penpot-sha-${SHORT_SHA}.zip" "$ZIP.$$.tmp"
mv "$ZIP.$$.tmp" "$ZIP"
fi
# Extract only the bundle this job needs.
pushd docker/images
unzip -q "$ZIP" "penpot/${IMAGE}/*"
mv "penpot/${IMAGE}" "bundle-${IMAGE}"
popd
- name: Set up QEMU (stable)
uses: docker/setup-qemu-action@v4
with:
platforms: linux/amd64,linux/arm64
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ matrix.image }}
labels: |
bundle_version=${{ needs.prepare.outputs.bundle_version }}
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: ./docker/images/
file: ./docker/images/Dockerfile.${{ matrix.image }}
platforms: linux/amd64,linux/arm64
push: true
provenance: mode=max
sbom: true
# Immutable tag only; branch tags are moved atomically for the
# whole image set by the `promote` job.
tags: ${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:sha-${{ needs.prepare.outputs.short_sha }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache
cache-to: type=registry,ref=${{ secrets.DOCKER_REGISTRY }}/${{ matrix.image }}:buildcache,mode=max
# ── 3a. Move the branch tags of ALL images together (fresh build) ──────
promote:
name: Promote image set
runs-on: penpot-standar-runner
timeout-minutes: 10
needs: [prepare, build]
steps:
- name: Set common environment variables
run: |
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Registry
uses: docker/login-action@v4
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Point branch tags to the new build key
env:
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
GH_REF: ${{ needs.prepare.outputs.gh_ref }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
run: |
set -e
for image in $ALL_IMAGES; do
docker buildx imagetools create \
-t "${DOCKER_REGISTRY}/${image}:${GH_REF}" \
"${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}"
done
# The marker is written LAST: its presence certifies that all five
# images exist and all branch tags point to this build key.
- name: Write set-completed marker
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }}
S3_BUCKET: ${{ secrets.S3_BUCKET }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
run: |
echo "$GITHUB_RUN_ID" | aws s3 cp - \
"s3://${S3_BUCKET}/markers/images-sha-${SHORT_SHA}"
- name: Write step summary
env:
GH_REF: ${{ needs.prepare.outputs.gh_ref }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }}
COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }}
run: |
{
echo "### ✅ Image set promoted"
echo ""
echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}"
echo "- Built at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "All \`:${GH_REF}\` tags now point to \`sha-${SHORT_SHA}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 3b. Skip path: make sure THIS ref's tags point to the existing
# images (another ref may have built and promoted the same commit
# first — the sha marker says the images exist, not that this ref's
# tags were ever created).
retag:
name: Ensure branch tags (already-built commit)
runs-on: penpot-standar-runner
timeout-minutes: 10
needs: prepare
if: needs.prepare.outputs.exists == 'true'
steps:
- name: Set common environment variables
run: |
echo "DOCKER_CONFIG=${{ runner.temp }}/.docker-${{ github.run_id }}-${{ github.job }}" >> $GITHUB_ENV
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to Docker Registry
uses: docker/login-action@v4
with:
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Point branch tags to the existing build key
env:
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
GH_REF: ${{ needs.prepare.outputs.gh_ref }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
run: |
set -e
for image in $ALL_IMAGES; do
docker buildx imagetools create \
-t "${DOCKER_REGISTRY}/${image}:${GH_REF}" \
"${DOCKER_REGISTRY}/${image}:sha-${SHORT_SHA}"
done
- name: Write step summary
env:
GH_REF: ${{ needs.prepare.outputs.gh_ref }}
SHORT_SHA: ${{ needs.prepare.outputs.short_sha }}
BUNDLE_VERSION: ${{ needs.prepare.outputs.bundle_version }}
COMMIT_TITLE: ${{ needs.prepare.outputs.commit_title }}
run: |
{
echo "### ✅ Image set already built (branch tags ensured)"
echo ""
echo "- Version: \`${BUNDLE_VERSION}\` (\`git describe --tags --always\`)"
echo "- Commit: [\`${SHORT_SHA}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${SHORT_SHA}) — ${COMMIT_TITLE}"
echo "- Checked at: $(date -u +'%Y-%m-%d %H:%M:%S UTC')"
echo ""
echo "All \`:${GH_REF}\` tags now point to \`sha-${SHORT_SHA}\`."
} >> "$GITHUB_STEP_SUMMARY"
# ── 4. Single failure notification for the whole workflow ─────────────
notify:
name: Notify failure
runs-on: penpot-standar-runner
timeout-minutes: 5
needs: [prepare, build, promote, retag]
if: failure()
steps:
- name: Notify Mattermost
uses: mattermost/action-mattermost-notify@ae31bb6f9e26a54336e79696f108a2c91cf55b4e # v2.1.0
with:
MATTERMOST_WEBHOOK_URL: ${{ secrets.MATTERMOST_WEBHOOK }}
MATTERMOST_CHANNEL: bot-alerts-cicd
TEXT: |
❌ 🐳 *[PENPOT] Error building/promoting the penpot docker image set.*
📄 Triggered from ref: `${{ needs.prepare.outputs.gh_ref || inputs.gh_ref }}`
📦 Bundle: `${{ needs.prepare.outputs.bundle_version || 'n/a' }}`
🔗 Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
@infra