mirror of
https://github.com/usetrmnl/trmnlp.git
synced 2026-08-13 14:27:33 -07:00
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.
216 lines
7.6 KiB
YAML
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 }}
|