Skip to content

Commit ab5358a

Browse files
CopilotCopilot
andauthored
fix(hub): pin and verify multiarchitecture production images (#19)
## Why The production Ruby and Node image tags could resolve to different contents without a source change. Pinning their official multiarchitecture indexes addresses code-scanning alerts 3 and 4 while retaining the current runtime tracks. ## What changes Hub CI builds AMD64 and ARM64 images in parallel from one job definition on native runners, avoiding QEMU overhead. Each image must pass the existing image checks and start successfully. The unchanged `Production image` check requires both architectures to pass and permits skipped builds only when suite selection explicitly excludes Hub. Releases also build each architecture on its native runner, using one validated version and immutable source revision. Build jobs publish by digest without updating release tags. One final job combines both digests and applies the existing stable or prerelease tags, then uploads the deployment bundle once. A failed architecture blocks that job. Release publication has not been exercised against GHCR; no release was triggered. The maintenance guide explains refreshing image tags and digests together. Existing Dependabot configuration does not update Docker images; no new update process is added. Alert 5 remains unchanged. Bundler deployment mode already freezes dependencies; the image's Bundler rejected inconsistent and missing lockfiles without rewriting them. The scanner rule should recognize deployment mode rather than require redundant configuration. --------- Co-authored-by: Copilot <[email protected]>
1 parent 556db18 commit ab5358a

6 files changed

Lines changed: 354 additions & 31 deletions

File tree

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

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,20 +274,68 @@ jobs:
274274
RAILS_ENV: production
275275
SECRET_KEY_BASE_DUMMY: "1"
276276

277-
production_image:
278-
name: Production image
277+
production_image_platform:
278+
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 }}
282+
strategy:
283+
fail-fast: false
284+
matrix:
285+
include:
286+
- architecture: amd64
287+
runner: ubuntu-latest
288+
- architecture: arm64
289+
runner: ubuntu-24.04-arm
290+
env:
291+
IMAGE_PLATFORM: linux/${{ matrix.architecture }}
282292
steps:
283293
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
284-
- run: docker build --build-context learning=../packages/learning --tag replica-hub:ci .
294+
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
295+
- run: docker buildx build --platform "$IMAGE_PLATFORM" --build-context learning=../packages/learning --tag replica-hub:ci --load .
285296
- name: Check build-time secret cleanup
286-
run: docker run --rm --entrypoint /bin/sh replica-hub:ci -c 'test ! -e /rails/tmp/local_secret.txt'
297+
run: docker run --platform "$IMAGE_PLATFORM" --rm --entrypoint /bin/sh replica-hub:ci -c 'test ! -e /rails/tmp/local_secret.txt'
287298
- name: Check deployment configuration
288299
run: |
289-
docker run --rm replica-hub:ci \
300+
umask 077
301+
docker run --platform "$IMAGE_PLATFORM" --rm replica-hub:ci \
290302
bin/replica-deployment-config generate \
291303
--host hub.example.com \
292304
--format dotenv > deploy/.env
293305
docker compose --file deploy/compose.yml --project-directory deploy config --quiet
306+
- name: Check production startup
307+
timeout-minutes: 3
308+
run: |
309+
docker run --platform "$IMAGE_PLATFORM" --detach --name replica-hub-ci --network none --env-file deploy/.env replica-hub:ci
310+
docker exec replica-hub-ci curl \
311+
--fail --silent --show-error \
312+
--retry 60 --retry-connrefused --retry-delay 2 --retry-max-time 120 --max-time 5 \
313+
http://localhost/up
314+
- name: Clean up production startup check
315+
if: ${{ always() }}
316+
run: |
317+
if docker container inspect replica-hub-ci >/dev/null 2>&1; then
318+
docker logs replica-hub-ci
319+
docker rm --force replica-hub-ci
320+
fi
321+
rm -f deploy/.env
322+
323+
production_image:
324+
name: Production image
325+
needs: [changes, production_image_platform]
326+
if: ${{ always() }}
327+
runs-on: ubuntu-latest
328+
steps:
329+
- name: Require both production images to pass
330+
working-directory: .
331+
env:
332+
SELECTION_RESULT: ${{ needs.changes.result }}
333+
SELECTED: ${{ needs.changes.outputs.hub }}
334+
IMAGE_RESULT: ${{ needs.production_image_platform.result }}
335+
run: |
336+
test "$SELECTION_RESULT" = success
337+
case "$SELECTED" in
338+
true) test "$IMAGE_RESULT" = success ;;
339+
false) test "$IMAGE_RESULT" = skipped ;;
340+
*) echo "Invalid Hub suite selection" >&2; exit 1 ;;
341+
esac

‎.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

‎docs/testing-and-review.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The [shared selection workflow](../.github/workflows/ci-changes.yml) calls [the
5656
| Root contributor documents, shared and Hub guides, skills, and documentation assets | Neither |
5757
| Workflow changes, selector changes, and unclassified paths | Both |
5858

59-
The workflows preserve the existing required check names; skipping an entire workflow with a path filter could leave those checks pending. Static jobs use job-level conditions. The four required Vitest shard jobs always expand their matrix and validate selection, but skip checkout, setup, and tests when Hub is unaffected. The required `Frontend tests` and `Replica action tests` result jobs accept omitted work only after successful selection explicitly excluded it. Selection failures, invalid outputs, and failures in selected suites fail those result checks. Keep required-check configuration unchanged.
59+
The workflows preserve the existing required check names; skipping an entire workflow with a path filter could leave those checks pending. Static jobs use job-level conditions. The four required Vitest shard jobs always expand their matrix and validate selection, but skip checkout, setup, and tests when Hub is unaffected. The required `Frontend tests`, `Production image`, and `Replica action tests` result jobs accept omitted work only after successful selection explicitly excluded it. The production-image matrix checks AMD64 and ARM64 independently without cancelling the other architecture on failure. Selection failures, invalid outputs, and failures in selected suites fail those result checks. Keep required-check configuration unchanged.
6060

6161
When adding a shared dependency or a new project, update the selector and its behavioral tests together. Unclassified paths deliberately run both suites until their dependencies are known. Run `node --test scripts/select-ci-suites.test.mjs` and `ruby scripts/check-ci-workflows.rb` from the repository root. The latter reads the actual workflow gates and checks their success, failure, cancellation, and missing-output behavior; both commands run before CI selects suites. Check changed workflows with `actionlint` when available. Actual job skipping and check reporting still need verification on GitHub after publishing.
6262

‎hub/Dockerfile‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@
77

88
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
99

10-
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
10+
# Keep RUBY_VERSION, .ruby-version, and the matching image index digest in sync.
1111
ARG RUBY_VERSION=4.0.5
12-
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
12+
FROM docker.io/library/ruby:$RUBY_VERSION-slim@sha256:42ad5227a4b2758f32563b34b5967e1055e75c80243eef04e6d2572887d1dec4 AS base
1313

1414
ARG BUNDLER_VERSION=4.0.13
1515

@@ -30,7 +30,7 @@ ENV RAILS_ENV="production" \
3030
BUNDLE_WITHOUT="development:test" \
3131
LD_PRELOAD="/usr/local/lib/libjemalloc.so"
3232

33-
FROM docker.io/library/node:24-bookworm-slim AS node
33+
FROM docker.io/library/node:24-bookworm-slim@sha256:a5f4366e834695d5f363b920c104953a535d3a9864d1bdf74566a0ebe01daee5 AS node
3434

3535
# Throw-away build stage to reduce size of final image
3636
FROM base AS build

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ The standard-button follow-up was checked in a fresh isolated browser on that sa
4848

4949
Follow the [shared dependency policy](../../docs/testing-and-review.md#update-dependencies), including the seven-day publication delay and security checks. Run `bin/devcontainer exec bin/check-dependency-age` for Hub lockfiles.
5050

51+
The production [Dockerfile](../Dockerfile) pins the official Ruby and Node images to SHA-256 image index digests. Each index includes Linux AMD64 and ARM64 images. When refreshing a pin, resolve its tag from Docker Hub's official `library/ruby` or `library/node` repository, verify both architectures with `docker buildx imagetools inspect`, and update the tag and its matching index digest together. Do not use a single-architecture manifest digest. Keep `RUBY_VERSION` and `.ruby-version` aligned with the Ruby image; overriding the version alone does not change the pinned image.
52+
53+
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.
54+
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.
56+
57+
`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.
58+
5159
Hub uses the native TypeScript compiler through `@typescript/native` for the `tsc` command and maps the `typescript` package name to a compiler-API compatibility package for tools such as typescript-eslint. Keep both aliases in [package.json](../package.json) until those tools support the native compiler's API.
5260

5361
## Keep local and GitHub CI aligned
@@ -133,6 +141,8 @@ It also recovers failed GitHub webhook deliveries every minute and cleans bounde
133141

134142
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.
135143

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+
136146
A stable release updates the exact, minor, major, and `latest` image tags. A prerelease updates only its exact version tag.
137147

138148
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)