Skip to content

Commit b2b4042

Browse files
committed
ci(hub): build multiarchitecture images on native runners
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0f8cb52 commit b2b4042

4 files changed

Lines changed: 277 additions & 26 deletions

File tree

‎.github/workflows/hub-ci.yml‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -278,19 +278,19 @@ jobs:
278278
name: Production image (${{ matrix.architecture }})
279279
needs: changes
280280
if: ${{ needs.changes.outputs.hub == 'true' }}
281-
runs-on: ubuntu-latest
281+
runs-on: ${{ matrix.runner }}
282282
strategy:
283283
fail-fast: false
284284
matrix:
285-
architecture: [amd64, arm64]
285+
include:
286+
- architecture: amd64
287+
runner: ubuntu-latest
288+
- architecture: arm64
289+
runner: ubuntu-24.04-arm
286290
env:
287291
IMAGE_PLATFORM: linux/${{ matrix.architecture }}
288292
steps:
289293
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
290-
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
291-
if: ${{ matrix.architecture == 'arm64' }}
292-
with:
293-
platforms: arm64
294294
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
295295
- run: docker buildx build --platform "$IMAGE_PLATFORM" --build-context learning=../packages/learning --tag replica-hub:ci --load .
296296
- name: Check build-time secret cleanup

‎.github/workflows/hub-release.yml‎

Lines changed: 98 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@ permissions:
99
packages: write
1010

1111
jobs:
12-
publish:
13-
name: Publish container and deployment bundle
12+
prepare:
13+
name: Prepare release metadata
1414
if: startsWith(github.event.release.tag_name, 'hub-v')
1515
runs-on: ubuntu-latest
16+
outputs:
17+
version: ${{ steps.release.outputs.version }}
18+
stable: ${{ steps.release.outputs.stable }}
19+
revision: ${{ steps.release.outputs.revision }}
20+
tags: ${{ steps.metadata.outputs.tags }}
21+
labels: ${{ steps.metadata.outputs.labels }}
1622
steps:
1723
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
1824
with:
@@ -49,16 +55,6 @@ jobs:
4955
echo "revision=$(git rev-parse HEAD)"
5056
} >> "$GITHUB_OUTPUT"
5157
52-
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
53-
with:
54-
registry: ghcr.io
55-
username: ${{ github.actor }}
56-
password: ${{ secrets.GITHUB_TOKEN }}
57-
58-
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
59-
60-
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
61-
6258
- uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
6359
id: metadata
6460
with:
@@ -70,18 +66,102 @@ jobs:
7066
type=semver,pattern={{major}},value=${{ steps.release.outputs.version }},enable=${{ steps.release.outputs.stable == 'true' }}
7167
type=raw,value=latest,enable=${{ steps.release.outputs.stable == 'true' }}
7268
69+
build:
70+
name: Build release image (${{ matrix.architecture }})
71+
needs: prepare
72+
runs-on: ${{ matrix.runner }}
73+
strategy:
74+
fail-fast: false
75+
matrix:
76+
include:
77+
- architecture: amd64
78+
runner: ubuntu-latest
79+
- architecture: arm64
80+
runner: ubuntu-24.04-arm
81+
steps:
82+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
83+
with:
84+
ref: ${{ needs.prepare.outputs.revision }}
85+
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
86+
with:
87+
registry: ghcr.io
88+
username: ${{ github.actor }}
89+
password: ${{ secrets.GITHUB_TOKEN }}
90+
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
7391
- uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
92+
id: build
7493
with:
7594
context: hub
7695
build-contexts: learning=packages/learning
77-
platforms: linux/amd64,linux/arm64
78-
push: true
79-
tags: ${{ steps.metadata.outputs.tags }}
80-
labels: ${{ steps.metadata.outputs.labels }}
96+
platforms: linux/${{ matrix.architecture }}
97+
outputs: type=image,name=ghcr.io/github/replica-hub,push-by-digest=true,name-canonical=true,push=true
98+
labels: ${{ needs.prepare.outputs.labels }}
8199
build-args: |
82-
REPLICA_IMAGE_REVISION=${{ steps.release.outputs.revision }}
83-
REPLICA_IMAGE_VERSION=${{ steps.release.outputs.version }}
100+
REPLICA_IMAGE_REVISION=${{ needs.prepare.outputs.revision }}
101+
REPLICA_IMAGE_VERSION=${{ needs.prepare.outputs.version }}
102+
- name: Export image digest
103+
env:
104+
IMAGE_DIGEST: ${{ steps.build.outputs.digest }}
105+
run: |
106+
if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then
107+
echo "Build did not return a SHA-256 image digest" >&2
108+
exit 1
109+
fi
110+
mkdir -p "$RUNNER_TEMP/digests"
111+
touch "$RUNNER_TEMP/digests/${IMAGE_DIGEST#sha256:}"
112+
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
113+
with:
114+
name: image-digest-${{ matrix.architecture }}
115+
path: ${{ runner.temp }}/digests/*
116+
if-no-files-found: error
117+
retention-days: 1
118+
119+
publish:
120+
name: Publish container and deployment bundle
121+
needs: [prepare, build]
122+
runs-on: ubuntu-latest
123+
steps:
124+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
125+
with:
126+
ref: ${{ needs.prepare.outputs.revision }}
127+
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
128+
with:
129+
registry: ghcr.io
130+
username: ${{ github.actor }}
131+
password: ${{ secrets.GITHUB_TOKEN }}
132+
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
133+
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
134+
with:
135+
path: ${{ runner.temp }}/digests
136+
pattern: image-digest-*
137+
- name: Publish multiarchitecture image
138+
env:
139+
IMAGE_TAGS: ${{ needs.prepare.outputs.tags }}
140+
run: |
141+
tag_args=()
142+
while IFS= read -r tag; do
143+
if [[ -z "$tag" ]]; then
144+
echo "Release metadata contains an empty image tag" >&2
145+
exit 1
146+
fi
147+
tag_args+=(--tag "$tag")
148+
done <<< "$IMAGE_TAGS"
84149
150+
refs=()
151+
for architecture in amd64 arm64; do
152+
files=("$RUNNER_TEMP/digests/image-digest-$architecture/"*)
153+
if [[ "${#files[@]}" != 1 || ! -f "${files[0]}" ]]; then
154+
echo "Expected exactly one image digest for $architecture" >&2
155+
exit 1
156+
fi
157+
digest=$(basename "${files[0]}")
158+
if [[ ! "$digest" =~ ^[a-f0-9]{64}$ ]]; then
159+
echo "Invalid image digest for $architecture" >&2
160+
exit 1
161+
fi
162+
refs+=("ghcr.io/github/replica-hub@sha256:$digest")
163+
done
164+
docker buildx imagetools create "${tag_args[@]}" "${refs[@]}"
85165
- name: Package deployment bundle
86166
run: |
87167
mkdir -p hub/tmp/release

‎hub/docs/testing-and-operations.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ The production [Dockerfile](../Dockerfile) pins the official Ruby and Node image
5252

5353
The existing [Dependabot configuration](../../.github/dependabot.yml) updates Bundler, npm, and GitHub Actions dependencies, but not Docker images. Rebuilding with a pinned digest does not pick up upstream base-image security fixes. Maintainers must refresh those pins to receive them. Adding Docker updates to Dependabot requires approval; do not introduce another updater. Before shipping refreshed pins, build both release architectures with the learning build context, run the production-image CI checks, and verify that the app starts.
5454

55-
Hub CI runs the same production-image checks in parallel for AMD64 and ARM64. The ARM64 job uses QEMU emulation on the standard runner. Each job builds and loads its image without publishing, checks secret cleanup and deployment configuration, and requires the production app's `/up` health endpoint to succeed. It removes the test container and generated configuration afterward. The required `Production image` result passes only when both architectures succeed, or when suite selection explicitly excludes Hub and the image jobs are skipped.
55+
Hub CI runs the same production-image checks in parallel on native AMD64 and ARM64 runners. Each job builds and loads its image without publishing, checks secret cleanup and deployment configuration, and requires the production app's `/up` health endpoint to succeed. It removes the test container and generated configuration afterward. The required `Production image` result passes only when both architectures succeed, or when suite selection explicitly excludes Hub and the image jobs are skipped.
5656

5757
`BUNDLE_DEPLOYMENT=1` makes Bundler installs frozen unless explicitly overridden by `BUNDLE_FROZEN=false`. The production build inherits deployment mode and copies `Gemfile.lock` before installing gems, so missing or inconsistent lockfiles fail rather than being rewritten. Scanner rules checking for frozen installs should recognize deployment mode, not require a redundant `BUNDLE_FROZEN` setting.
5858

@@ -141,6 +141,8 @@ It also recovers failed GitHub webhook deliveries every minute and cleans bounde
141141

142142
Create a GitHub release from a Hub semantic version tag in the form `hub-v<major>.<minor>.<patch>`. Publishing the release builds AMD64 and ARM64 images, pushes the applicable tags to `ghcr.io/github/replica-hub`, and uploads the deployment bundle and its checksum.
143143

144+
The workflow builds each architecture in parallel on its native runner, using the same source revision, version, and image labels. Build jobs push immutable image digests without updating release tags. Only after both builds succeed does the publication job combine their digests into one multiarchitecture image and apply the release tags. If either build fails, no release tags or deployment bundle are published.
145+
144146
A stable release updates the exact, minor, major, and `latest` image tags. A prerelease updates only its exact version tag.
145147

146148
After the first publication, a repository administrator must confirm that the package is linked to this repository and set its visibility to **Public**. Confirm that a signed-out user can pull the image before announcing the release.

0 commit comments

Comments
 (0)