name: Release on: push: tags: - "v*.*.*" env: REGISTRY: ghcr.io IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}/warmbly jobs: validate-tag: name: Validate Tag runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} major: ${{ steps.version.outputs.major }} minor: ${{ steps.version.outputs.minor }} steps: - name: Extract version id: version run: | TAG="${{ github.ref_name }}" VERSION="${TAG#v}" echo "version=${VERSION}" >> $GITHUB_OUTPUT echo "major=$(echo "$VERSION" | cut -d. -f1)" >> $GITHUB_OUTPUT echo "minor=$(echo "$VERSION" | cut -d. -f1-2)" >> $GITHUB_OUTPUT echo "Release version: ${VERSION}" # Go services: the Dockerfiles build on $BUILDPLATFORM and cross-compile to # each target arch, so one amd64 runner produces both platforms without QEMU. build-go: name: Build ${{ matrix.service }} needs: validate-tag strategy: fail-fast: false matrix: service: [backend, consumer, worker] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: . file: deploy/docker/${{ matrix.service }}.Dockerfile push: true tags: | ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=${{ matrix.service }} cache-to: type=gha,mode=max,scope=${{ matrix.service }} # Frontends (web dashboard, admin panel): the pnpm build runs once on the # build platform and its static output is served from nginx, so a single # buildx build produces both arches without a per-arch rebuild. build-frontend: name: Build ${{ matrix.service }} needs: validate-tag strategy: fail-fast: false matrix: service: [web, admin] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v6 with: context: ./${{ matrix.service }} file: ./${{ matrix.service }}/Dockerfile push: true tags: | ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }} ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=${{ matrix.service }} cache-to: type=gha,mode=max,scope=${{ matrix.service }} # Rust (tracking) and Elixir (realtime) have no cross-compiler; an emulated # arm64 build under QEMU runs for an hour or more. Build each arch on a # native runner and merge the digests into one manifest (the # Docker-documented multi-runner pattern). build-native: name: Build ${{ matrix.service }} (${{ matrix.platform }}) needs: validate-tag strategy: fail-fast: false matrix: service: [tracking, realtime] platform: [linux/amd64, linux/arm64] runs-on: ${{ matrix.platform == 'linux/arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }} permissions: contents: read packages: write steps: - uses: actions/checkout@v4 - name: Prepare platform pair id: prep run: echo "pair=${PLATFORM//\//-}" >> "$GITHUB_OUTPUT" env: PLATFORM: ${{ matrix.platform }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push by digest id: build uses: docker/build-push-action@v6 with: context: ${{ matrix.service == 'tracking' && './tracking' || '.' }} file: ${{ matrix.service == 'tracking' && './tracking/Dockerfile' || format('deploy/docker/{0}.Dockerfile', matrix.service) }} platforms: ${{ matrix.platform }} cache-from: type=gha,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} cache-to: type=gha,mode=max,scope=${{ matrix.service }}-${{ steps.prep.outputs.pair }} outputs: type=image,name=${{ env.IMAGE_PREFIX }}/${{ matrix.service }},push-by-digest=true,name-canonical=true,push=true - name: Export digest run: | mkdir -p /tmp/digests digest="${{ steps.build.outputs.digest }}" touch "/tmp/digests/${digest#sha256:}" - name: Upload digest uses: actions/upload-artifact@v4 with: name: digests-${{ matrix.service }}-${{ steps.prep.outputs.pair }} path: /tmp/digests/* if-no-files-found: error retention-days: 1 merge-native: name: Merge ${{ matrix.service }} manifest needs: [validate-tag, build-native] strategy: fail-fast: false matrix: service: [tracking, realtime] runs-on: ubuntu-latest permissions: contents: read packages: write steps: - name: Download digests uses: actions/download-artifact@v4 with: path: /tmp/digests pattern: digests-${{ matrix.service }}-* merge-multiple: true - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Create manifest list and push working-directory: /tmp/digests run: | docker buildx imagetools create \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:${{ github.ref_name }} \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.minor }} \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:v${{ needs.validate-tag.outputs.major }} \ -t ${{ env.IMAGE_PREFIX }}/${{ matrix.service }}:prod \ $(printf '${{ env.IMAGE_PREFIX }}/${{ matrix.service }}@sha256:%s ' *) create-release: name: Create GitHub Release needs: [validate-tag, build-go, build-frontend, merge-native] runs-on: ubuntu-latest permissions: contents: write steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Generate changelog id: changelog run: | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") if [[ -n "$PREV_TAG" ]]; then echo "Generating changelog from $PREV_TAG to ${{ github.ref_name }}" CHANGELOG=$(git log --pretty=format:"- %s (%h)" $PREV_TAG..${{ github.ref_name }} | head -50) else echo "No previous tag found, generating from beginning" CHANGELOG=$(git log --pretty=format:"- %s (%h)" | head -50) fi # Escape for GitHub Actions CHANGELOG="${CHANGELOG//'%'/'%25'}" CHANGELOG="${CHANGELOG//$'\n'/'%0A'}" CHANGELOG="${CHANGELOG//$'\r'/'%0D'}" echo "changelog=${CHANGELOG}" >> $GITHUB_OUTPUT - name: Create Release uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} name: Release ${{ github.ref_name }} body: | ## What's Changed ${{ steps.changelog.outputs.changelog }} ## Docker Images All images are available at `ghcr.io/${{ github.repository_owner }}/warmbly`: | Service | Image | |---------|-------| | Backend | `${{ env.IMAGE_PREFIX }}/backend:${{ github.ref_name }}` | | Consumer | `${{ env.IMAGE_PREFIX }}/consumer:${{ github.ref_name }}` | | Worker | `${{ env.IMAGE_PREFIX }}/worker:${{ github.ref_name }}` | | Tracking | `${{ env.IMAGE_PREFIX }}/tracking:${{ github.ref_name }}` | | Realtime | `${{ env.IMAGE_PREFIX }}/realtime:${{ github.ref_name }}` | | Dashboard (web) | `${{ env.IMAGE_PREFIX }}/web:${{ github.ref_name }}` | | Admin | `${{ env.IMAGE_PREFIX }}/admin:${{ github.ref_name }}` | ## Deployment This release will be automatically deployed to production. draft: false prerelease: ${{ contains(github.ref_name, '-') }} generate_release_notes: true