Skip to content

Commit 7593540

Browse files
committed
ci: build release Docker images natively per-arch, merge with a manifest
The v0.6.0 release run's multi-arch Docker build wedged for 3+ hours emulating the arm64 Rust release build under QEMU, and — because the workflow concurrency group is cancel-in-progress:false — every queued release run sat blocked behind it. - docker is now a matrix: amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm — both native, mirroring release-binaries. Each leg smoke-tests the image (--version must report the released version) and pushes by digest only. - docker-manifest stitches the digests into the :X.Y.Z, :X.Y and :latest tags with buildx imagetools, so a half-finished matrix can never publish a partially-tagged image. - workflow_dispatch grows docker_backfill_tag (and npm_backfill_tag becomes optional): each channel backfills independently for an already-cut release — needed to publish the missing v0.6.0 image.
1 parent ccc2cf4 commit 7593540

1 file changed

Lines changed: 156 additions & 34 deletions

File tree

‎.github/workflows/release.yml‎

Lines changed: 156 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@ on:
44
push:
55
branches:
66
- main
7-
# Manual backfill: publish an already-cut release to npm (e.g. after the
8-
# npm side of trusted publishing is configured, or a publish was missed).
9-
# Only the npm-publish job runs; binaries are downloaded from the existing
10-
# GitHub release for the given tag.
7+
# Manual backfill: republish an already-cut release to a channel that
8+
# missed it. Fill in only the channel(s) you want — an empty input leaves
9+
# that channel's jobs skipped. npm reuses the existing release's binary
10+
# assets; docker rebuilds the image from the tag and re-tags the manifest.
1111
workflow_dispatch:
1212
inputs:
1313
npm_backfill_tag:
1414
description: "Existing release tag to publish to npm (e.g. v0.6.0)"
15-
required: true
15+
required: false
16+
type: string
17+
docker_backfill_tag:
18+
description: "Existing release tag to publish to ghcr (e.g. v0.6.0)"
19+
required: false
1620
type: string
1721

1822
concurrency:
@@ -44,24 +48,57 @@ jobs:
4448
with:
4549
token: ${{ secrets.GITHUB_TOKEN }}
4650

51+
# Each architecture builds NATIVELY (amd64 on ubuntu-latest, arm64 on
52+
# ubuntu-24.04-arm) and pushes by digest; docker-manifest below stitches the
53+
# digests into the tagged multi-arch image. No QEMU: emulating the arm64
54+
# Rust release build wedged the v0.6.0 run for 3+ hours and, via the
55+
# workflow concurrency group, blocked every queued release run behind it.
4756
docker:
48-
name: Build & Push Docker Image
57+
name: Build & Push Docker Image (${{ matrix.arch }})
4958
needs: release-please
50-
if: ${{ needs.release-please.outputs.release_created == 'true' }}
51-
runs-on: ubuntu-latest
59+
# Runs on a fresh release, or on manual dispatch for an existing tag
60+
# (release-please is skipped on dispatch, hence !cancelled()).
61+
if: >-
62+
${{ !cancelled() && (
63+
needs.release-please.outputs.release_created == 'true' ||
64+
(github.event_name == 'workflow_dispatch' && inputs.docker_backfill_tag != '')
65+
) }}
66+
strategy:
67+
fail-fast: false
68+
matrix:
69+
include:
70+
- arch: amd64
71+
os: ubuntu-latest
72+
platform: linux/amd64
73+
- arch: arm64
74+
os: ubuntu-24.04-arm
75+
platform: linux/arm64
76+
runs-on: ${{ matrix.os }}
5277
permissions:
5378
contents: read
5479
packages: write
5580
steps:
81+
- name: Resolve release tag
82+
id: rel
83+
env:
84+
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
85+
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
86+
run: |
87+
set -euo pipefail
88+
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
89+
case "$TAG" in
90+
v[0-9]*) ;;
91+
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
92+
esac
93+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
94+
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
95+
5696
- name: Checkout release tag
5797
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
5898
with:
59-
ref: ${{ needs.release-please.outputs.tag_name }}
99+
ref: ${{ steps.rel.outputs.tag }}
60100
persist-credentials: false
61101

62-
- name: Set up QEMU
63-
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
64-
65102
- name: Set up Docker Buildx
66103
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
67104

@@ -72,45 +109,130 @@ jobs:
72109
username: ${{ github.actor }}
73110
password: ${{ secrets.GITHUB_TOKEN }}
74111

75-
- name: Extract metadata
76-
id: meta
77-
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
78-
with:
79-
images: ghcr.io/${{ github.repository }}
80-
tags: |
81-
type=semver,pattern={{version}},value=${{ needs.release-please.outputs.version }}
82-
type=semver,pattern={{major}}.{{minor}},value=${{ needs.release-please.outputs.version }}
83-
type=raw,value=latest
84-
85112
- name: Build and load locally (smoke)
86113
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
87114
with:
88115
context: .
116+
platforms: ${{ matrix.platform }}
89117
load: true
90118
tags: gitlawb-node:smoke
91-
cache-from: type=gha
119+
cache-from: type=gha,scope=docker-${{ matrix.arch }}
92120

121+
# Native runner on both arches, so the built image can always execute.
122+
# Assert the released version so a stale artifact fails here, not in prod.
93123
- name: Smoke test
94-
run: docker run --rm gitlawb-node:smoke --version
124+
env:
125+
VERSION: ${{ steps.rel.outputs.version }}
126+
run: |
127+
set -euo pipefail
128+
out="$(docker run --rm gitlawb-node:smoke --version)"
129+
echo "$out"
130+
grep -qF "$VERSION" <<<"$out" || {
131+
echo "::error::image --version did not report expected version $VERSION (got: $out)"
132+
exit 1
133+
}
95134
96-
- name: Build and push (multi-arch)
135+
# Push by digest only — tags are applied once by docker-manifest so a
136+
# half-finished matrix can never publish a partially-tagged image.
137+
# provenance:false keeps each push a plain single-arch manifest, which is
138+
# what imagetools create expects to merge.
139+
- name: Build and push by digest
140+
id: push
97141
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
98142
with:
99143
context: .
100-
platforms: linux/amd64,linux/arm64
101-
push: true
102-
tags: ${{ steps.meta.outputs.tags }}
103-
labels: ${{ steps.meta.outputs.labels }}
104-
cache-from: type=gha
105-
cache-to: type=gha,mode=max
144+
platforms: ${{ matrix.platform }}
145+
provenance: false
146+
outputs: type=image,name=ghcr.io/${{ github.repository }},push-by-digest=true,name-canonical=true,push=true
147+
cache-from: type=gha,scope=docker-${{ matrix.arch }}
148+
cache-to: type=gha,mode=max,scope=docker-${{ matrix.arch }}
149+
150+
- name: Export digest
151+
env:
152+
DIGEST: ${{ steps.push.outputs.digest }}
153+
run: |
154+
set -euo pipefail
155+
mkdir -p /tmp/digests
156+
touch "/tmp/digests/${DIGEST#sha256:}"
157+
158+
- name: Upload digest
159+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
160+
with:
161+
name: docker-digest-${{ matrix.arch }}
162+
path: /tmp/digests/*
163+
if-no-files-found: error
164+
retention-days: 1
165+
166+
docker-manifest:
167+
name: Publish Docker manifest
168+
needs: [release-please, docker]
169+
if: ${{ !cancelled() && needs.docker.result == 'success' }}
170+
runs-on: ubuntu-latest
171+
permissions:
172+
packages: write
173+
steps:
174+
- name: Resolve release tag
175+
id: rel
176+
env:
177+
DISPATCH_TAG: ${{ inputs.docker_backfill_tag }}
178+
RELEASE_TAG: ${{ needs.release-please.outputs.tag_name }}
179+
run: |
180+
set -euo pipefail
181+
TAG="${DISPATCH_TAG:-$RELEASE_TAG}"
182+
case "$TAG" in
183+
v[0-9]*) ;;
184+
*) echo "::error::'$TAG' does not look like a release tag (vX.Y.Z)"; exit 1 ;;
185+
esac
186+
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
187+
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
188+
189+
- name: Download digests
190+
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
191+
with:
192+
pattern: docker-digest-*
193+
path: /tmp/digests
194+
merge-multiple: true
195+
196+
- name: Set up Docker Buildx
197+
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
198+
199+
- name: Log in to GitHub Container Registry
200+
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
201+
with:
202+
registry: ghcr.io
203+
username: ${{ github.actor }}
204+
password: ${{ secrets.GITHUB_TOKEN }}
205+
206+
- name: Create and push multi-arch manifest
207+
env:
208+
VERSION: ${{ steps.rel.outputs.version }}
209+
run: |
210+
set -euo pipefail
211+
# ghcr requires a lowercase repository path.
212+
IMAGE="ghcr.io/${GITHUB_REPOSITORY,,}"
213+
MAJOR_MINOR="${VERSION%.*}"
214+
digests=""
215+
for f in /tmp/digests/*; do
216+
digests="$digests $IMAGE@sha256:$(basename "$f")"
217+
done
218+
# shellcheck disable=SC2086
219+
docker buildx imagetools create \
220+
-t "$IMAGE:$VERSION" \
221+
-t "$IMAGE:$MAJOR_MINOR" \
222+
-t "$IMAGE:latest" \
223+
$digests
224+
docker buildx imagetools inspect "$IMAGE:$VERSION"
106225
107226
- name: Release summary
227+
env:
228+
VERSION: ${{ steps.rel.outputs.version }}
229+
TAG: ${{ steps.rel.outputs.tag }}
108230
run: |
109231
{
110-
echo "## Released ${{ needs.release-please.outputs.tag_name }}"
232+
echo "## Released $TAG"
111233
echo
112-
echo "- Image: \`ghcr.io/${{ github.repository }}:${{ needs.release-please.outputs.version }}\`"
113-
echo "- GitHub: https://github.com/${{ github.repository }}/releases/tag/${{ needs.release-please.outputs.tag_name }}"
234+
echo "- Image: \`ghcr.io/${GITHUB_REPOSITORY,,}:$VERSION\` (linux/amd64 + linux/arm64)"
235+
echo "- GitHub: https://github.com/$GITHUB_REPOSITORY/releases/tag/$TAG"
114236
} >> "$GITHUB_STEP_SUMMARY"
115237
116238
release-binaries:

0 commit comments

Comments
 (0)