Files
Ikraam Ghoor 8bcd8aa751 Refactored release workflow for parallel multi-arch builds
Necessary so a release takes ~3 minutes cold or ~45 seconds warm
instead of 13+ minutes. The old workflow built linux/arm64 under
QEMU emulation on an x86 runner, so every arm64 instruction was
software-translated and the apt-get install of imagemagick, firefox,
python, node, php, and git compounded the slowdown.

The new workflow splits the build across a matrix: amd64 on
ubuntu-latest and arm64 on ubuntu-24.04-arm, native, in parallel.
A third job stitches the per-arch digests into the multi-arch
manifest tags. cache-from/cache-to (type=gha, per-arch scope) makes
warm reruns near-instant.

Verified on the release-workflow-test branch: cold 3m 53s, warm 44s.
2026-05-20 12:50:37 +01:00

216 lines
7.6 KiB
YAML

# Triggered when lib/trmnlp/version.rb changes on main (i.e. a version
# bump is merged). Creates a git tag, publishes the gem to RubyGems, and
# builds + pushes the multi-arch Docker image.
#
# Each release step is idempotent: it checks whether the artifact already
# exists and skips if so. The workflow can be safely re-run after a
# partial failure (e.g. tag pushed but gem publish failed).
#
# Architecture:
# prepare - tag, gem publish, decide whether image needs building
# docker-build - matrix: amd64 on x86 runner, arm64 on native arm runner
# docker-manifest - merge per-arch digests into the multi-arch tags
#
# Why split per-arch: building linux/arm64 under QEMU emulation on an x86
# runner takes ~13 min. Native arm64 (`ubuntu-24.04-arm`) takes ~3 min, in
# parallel with amd64. Layer caching (type=gha) makes warm reruns faster
# still. Total release wall time drops from ~14 min to ~3 min.
#
# Required repository secrets:
# RUBYGEMS_API_KEY - API key from https://rubygems.org/profile/api_keys
# DOCKER_USERNAME - Docker Hub username
# DOCKER_PASSWORD - Docker Hub access token
name: Release
on:
push:
branches: [main]
paths:
- "lib/trmnlp/version.rb"
# Serialize releases across the WHOLE workflow: a second run queues
# behind the first instead of racing it on the tag push. Never cancel
# an in-flight release — that could interrupt a publish midway.
concurrency:
group: release
cancel-in-progress: false
env:
IMAGE: trmnl/trmnlp
jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: write # needed to push the git tag
outputs:
version: ${{ steps.read.outputs.version }} # e.g. v0.8.3
gem_version: ${{ steps.read.outputs.gem_version }} # e.g. 0.8.3
ruby_version: ${{ steps.read.outputs.ruby_version }}
image_exists: ${{ steps.image.outputs.exists }}
has_docker_creds: ${{ steps.dockercreds.outputs.has }}
steps:
- uses: actions/checkout@v6
- name: Read versions
id: read
run: |
GEM_VERSION=$(ruby -r ./lib/trmnlp/version -e 'puts TRMNLP::VERSION')
echo "version=v${GEM_VERSION}" >> "$GITHUB_OUTPUT"
echo "gem_version=${GEM_VERSION}" >> "$GITHUB_OUTPUT"
echo "ruby_version=$(cat .ruby-version)" >> "$GITHUB_OUTPUT"
- uses: ruby/setup-ruby@v1
with:
ruby-version-file: .ruby-version
bundler-cache: true
- name: Run specs
run: bundle exec rspec
# Git tag
- name: Check if tag already exists
id: tag
run: |
if git ls-remote --tags origin "refs/tags/${{ steps.read.outputs.version }}" | grep -q .; then
echo "exists=true" >> "$GITHUB_OUTPUT"
fi
- name: Create git tag
if: steps.tag.outputs.exists != 'true'
run: |
git tag "${{ steps.read.outputs.version }}"
git push origin "${{ steps.read.outputs.version }}"
# RubyGems
- name: Check if gem version already published
id: gem
run: |
if gem info trmnl_preview -r -e -v "${{ steps.read.outputs.gem_version }}" 2>/dev/null | grep -q "${{ steps.read.outputs.gem_version }}"; then
echo "exists=true" >> "$GITHUB_OUTPUT"
fi
- name: Check for RubyGems API key
id: rubygemskey
env:
RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
run: |
if [ -n "$RUBYGEMS_API_KEY" ]; then
echo "has=true" >> "$GITHUB_OUTPUT"
fi
- name: Build and publish gem
if: steps.gem.outputs.exists != 'true' && steps.rubygemskey.outputs.has == 'true'
run: |
gem build trmnl_preview.gemspec
gem push trmnl_preview-*.gem
env:
GEM_HOST_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}
- name: Warn that gem publish was skipped
if: steps.gem.outputs.exists != 'true' && steps.rubygemskey.outputs.has != 'true'
run: echo "::warning::RUBYGEMS_API_KEY not set — gem ${{ steps.read.outputs.gem_version }} was NOT published. Push it manually."
# Docker pre-check — short-circuits both arch jobs if image already exists
- name: Check if Docker image already exists
id: image
run: |
if docker manifest inspect "${{ env.IMAGE }}:${{ steps.read.outputs.version }}" > /dev/null 2>&1; then
echo "exists=true" >> "$GITHUB_OUTPUT"
fi
- name: Check for Docker Hub credentials
id: dockercreds
env:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
if [ -n "$DOCKER_USERNAME" ] && [ -n "$DOCKER_PASSWORD" ]; then
echo "has=true" >> "$GITHUB_OUTPUT"
fi
# ---- Per-arch image builds, in parallel, on NATIVE runners ----
docker-build:
needs: prepare
if: needs.prepare.outputs.image_exists != 'true' && needs.prepare.outputs.has_docker_creds == 'true'
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
arch: amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
arch: arm64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# Build for this single arch and push by digest (no tag yet).
# The manifest job below stitches the per-arch digests into the
# final `:latest` and `:vX.Y.Z` multi-arch tags.
- id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
build-args: |
RUBY_VERSION=${{ needs.prepare.outputs.ruby_version }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
# Export this build's digest as an artifact so the manifest job
# can pick it up. The empty file's NAME is the digest.
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---- Stitch the per-arch digests into a multi-arch manifest ----
docker-manifest:
needs: [prepare, docker-build]
if: needs.prepare.outputs.image_exists != 'true' && needs.prepare.outputs.has_docker_creds == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Create multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create \
-t ${{ env.IMAGE }}:latest \
-t ${{ env.IMAGE }}:${{ needs.prepare.outputs.version }} \
$(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect image
run: docker buildx imagetools inspect ${{ env.IMAGE }}:${{ needs.prepare.outputs.version }}