name: Docker Images Builder on: workflow_dispatch: inputs: gh_ref: description: 'Name of the branch or ref' type: string required: true default: 'develop' workflow_call: inputs: gh_ref: description: 'Name of the branch or ref' type: string required: true default: 'develop' concurrency: group: ${{ github.workflow }}-${{ 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-runner-02 timeout-minutes: 15 outputs: gh_ref: ${{ steps.vars.outputs.gh_ref }} bundle_version: ${{ steps.vars.outputs.bundle_version }} build_key: ${{ steps.vars.outputs.build_key }} exists: ${{ steps.check.outputs.exists }} steps: - name: Checkout code uses: actions/checkout@v6 with: ref: ${{ inputs.gh_ref }} - name: Extract some useful variables id: vars 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 }} run: | GH_REF="${{ inputs.gh_ref || github.ref_name }}" echo "gh_ref=$GH_REF" >> $GITHUB_OUTPUT BUNDLE_VERSION=$(aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ --key "penpot-$GH_REF.zip" \ --query 'Metadata."bundle-version"' \ --output text) echo "bundle_version=$BUNDLE_VERSION" >> $GITHUB_OUTPUT # Image content = bundle + docker build context, so the build key # combines both. CTX_HASH=$(git rev-parse "HEAD:docker/images" | cut -c1-12) echo "build_key=${BUNDLE_VERSION}-${CTX_HASH}" >> $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 build key. - 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 }} run: | if aws s3api head-object \ --bucket ${{ secrets.S3_BUCKET }} \ --key "markers/images-${{ steps.vars.outputs.build_key }}" \ > /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 \`${{ steps.vars.outputs.build_key }}\`." } >> "$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-${{ steps.vars.outputs.build_key }}.zip" if [ ! -f "$ZIP" ]; then aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ steps.vars.outputs.gh_ref }}.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-runner-02 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.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 }} run: | ZIP="$BUNDLE_CACHE/penpot-${{ needs.prepare.outputs.build_key }}.zip" if [ ! -f "$ZIP" ]; then echo "Bundle not found in host cache; falling back to S3." mkdir -p "$BUNDLE_CACHE" aws s3 cp "s3://${{ secrets.S3_BUCKET }}/penpot-${{ needs.prepare.outputs.gh_ref }}.zip" "$ZIP.$$.tmp" mv "$ZIP.$$.tmp" "$ZIP" fi # Extract only the bundle this job needs. pushd docker/images unzip -q "$ZIP" "penpot/${{ matrix.image }}/*" mv "penpot/${{ matrix.image }}" "bundle-${{ matrix.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 }}:build-${{ needs.prepare.outputs.build_key }} 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 # ── 3. Move the branch tags of ALL images together ───────────────────── # Runs only when every build succeeded (default `needs` semantics); if # the set was already complete, `build` is skipped and so is this job — # the S3 marker guarantees the branch tags were already moved. promote: name: Promote image set runs-on: penpot-runner-02 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 run: | set -e for image in $ALL_IMAGES; do docker buildx imagetools create \ -t "${{ secrets.DOCKER_REGISTRY }}/$image:${{ needs.prepare.outputs.gh_ref }}" \ "${{ secrets.DOCKER_REGISTRY }}/$image:build-${{ needs.prepare.outputs.build_key }}" 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 }} run: | echo "${{ github.run_id }}" | aws s3 cp - \ "s3://${{ secrets.S3_BUCKET }}/markers/images-${{ needs.prepare.outputs.build_key }}" { echo "### ✅ Image set promoted" echo "" echo "All \`:${{ needs.prepare.outputs.gh_ref }}\` tags now point to \`build-${{ needs.prepare.outputs.build_key }}\`." } >> "$GITHUB_STEP_SUMMARY" # ── 4. Single failure notification for the whole workflow ───────────── notify: name: Notify failure runs-on: penpot-runner-02 timeout-minutes: 5 needs: [prepare, build, promote] 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