From 8df976a539b7cc99d2d411bc530dbac5eb8aa916 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:38:11 +0000 Subject: [PATCH 1/3] fix(hub): pin and verify multiarchitecture production images Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/hub-ci.yml | 54 ++++++++++++++++++++++++++++-- docs/testing-and-review.md | 2 +- hub/Dockerfile | 6 ++-- hub/docs/testing-and-operations.md | 8 +++++ scripts/check-ci-workflows.rb | 20 +++++++++-- 5 files changed, 80 insertions(+), 10 deletions(-) diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index 385f087..9d6c875 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -274,20 +274,68 @@ jobs: RAILS_ENV: production SECRET_KEY_BASE_DUMMY: "1" - production_image: - name: Production image + production_image_platform: + name: Production image (${{ matrix.architecture }}) needs: changes if: ${{ needs.changes.outputs.hub == 'true' }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + architecture: [amd64, arm64] + env: + DOCKER_DEFAULT_PLATFORM: linux/${{ matrix.architecture }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - run: docker build --build-context learning=../packages/learning --tag replica-hub:ci . + - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 + if: ${{ matrix.architecture == 'arm64' }} + with: + platforms: arm64 + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - run: docker buildx build --platform "$DOCKER_DEFAULT_PLATFORM" --build-context learning=../packages/learning --tag replica-hub:ci --load . - name: Check build-time secret cleanup run: docker run --rm --entrypoint /bin/sh replica-hub:ci -c 'test ! -e /rails/tmp/local_secret.txt' - name: Check deployment configuration run: | + umask 077 docker run --rm replica-hub:ci \ bin/replica-deployment-config generate \ --host hub.example.com \ --format dotenv > deploy/.env docker compose --file deploy/compose.yml --project-directory deploy config --quiet + - name: Check production startup + timeout-minutes: 3 + run: | + docker run --detach --name replica-hub-ci --network none --env-file deploy/.env replica-hub:ci + docker exec replica-hub-ci curl \ + --fail --silent --show-error \ + --retry 60 --retry-connrefused --retry-delay 2 --retry-max-time 120 --max-time 5 \ + http://localhost/up + - name: Clean up production startup check + if: ${{ always() }} + run: | + if docker container inspect replica-hub-ci >/dev/null 2>&1; then + docker logs replica-hub-ci + docker rm --force replica-hub-ci + fi + rm -f deploy/.env + + production_image: + name: Production image + needs: [changes, production_image_platform] + if: ${{ always() }} + runs-on: ubuntu-latest + steps: + - name: Require both production images to pass + working-directory: . + env: + SELECTION_RESULT: ${{ needs.changes.result }} + SELECTED: ${{ needs.changes.outputs.hub }} + IMAGE_RESULT: ${{ needs.production_image_platform.result }} + run: | + test "$SELECTION_RESULT" = success + case "$SELECTED" in + true) test "$IMAGE_RESULT" = success ;; + false) test "$IMAGE_RESULT" = skipped ;; + *) echo "Invalid Hub suite selection" >&2; exit 1 ;; + esac diff --git a/docs/testing-and-review.md b/docs/testing-and-review.md index cd8bbcb..05d8c0d 100644 --- a/docs/testing-and-review.md +++ b/docs/testing-and-review.md @@ -56,7 +56,7 @@ The [shared selection workflow](../.github/workflows/ci-changes.yml) calls [the | Root contributor documents, shared and Hub guides, skills, and documentation assets | Neither | | Workflow changes, selector changes, and unclassified paths | Both | -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. +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. 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. diff --git a/hub/Dockerfile b/hub/Dockerfile index 33a2e40..aa9bbe3 100644 --- a/hub/Dockerfile +++ b/hub/Dockerfile @@ -7,9 +7,9 @@ # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html -# Make sure RUBY_VERSION matches the Ruby version in .ruby-version +# Keep RUBY_VERSION, .ruby-version, and the matching image index digest in sync. ARG RUBY_VERSION=4.0.5 -FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base +FROM docker.io/library/ruby:$RUBY_VERSION-slim@sha256:42ad5227a4b2758f32563b34b5967e1055e75c80243eef04e6d2572887d1dec4 AS base ARG BUNDLER_VERSION=4.0.13 @@ -30,7 +30,7 @@ ENV RAILS_ENV="production" \ BUNDLE_WITHOUT="development:test" \ LD_PRELOAD="/usr/local/lib/libjemalloc.so" -FROM docker.io/library/node:24-bookworm-slim AS node +FROM docker.io/library/node:24-bookworm-slim@sha256:a5f4366e834695d5f363b920c104953a535d3a9864d1bdf74566a0ebe01daee5 AS node # Throw-away build stage to reduce size of final image FROM base AS build diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 3509a37..3545e68 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -48,6 +48,14 @@ The standard-button follow-up was checked in a fresh isolated browser on that sa 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. +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. + +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. + +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. + +`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. + 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. ## Keep local and GitHub CI aligned diff --git a/scripts/check-ci-workflows.rb b/scripts/check-ci-workflows.rb index fe3f0b6..5a79958 100644 --- a/scripts/check-ci-workflows.rb +++ b/scripts/check-ci-workflows.rb @@ -16,6 +16,7 @@ def expression(value) expected_gates = { ["hub-ci", "frontend_test_shard"] => ["Frontend tests shard ${{ matrix.shard }}/4", ["changes"]], ["hub-ci", "frontend_test"] => ["Frontend tests", %w[changes frontend_test_shard frontend_integration_test]], + ["hub-ci", "production_image"] => ["Production image", %w[changes production_image_platform]], ["actions-ci", "result"] => ["Replica action tests", %w[changes test]] } gates = {} @@ -47,8 +48,13 @@ def expression(value) env = guard.fetch("env") check(expression(env["SELECTION_RESULT"]) == "needs.changes.result", "#{label}: incorrect selection result") check(expression(env["SELECTED"]) == "needs.changes.outputs.#{suite}", "#{label}: incorrect selection output") - result_jobs = { "SHARD_RESULT" => "frontend_test_shard", "INTEGRATION_RESULT" => "frontend_integration_test", "TEST_RESULT" => "test" } - expected_results = id == "frontend_test" ? %w[SHARD_RESULT INTEGRATION_RESULT] : id == "result" ? ["TEST_RESULT"] : [] + result_jobs = { "SHARD_RESULT" => "frontend_test_shard", "INTEGRATION_RESULT" => "frontend_integration_test", "TEST_RESULT" => "test", "IMAGE_RESULT" => "production_image_platform" } + expected_results = case id + when "frontend_test" then %w[SHARD_RESULT INTEGRATION_RESULT] + when "result" then ["TEST_RESULT"] + when "production_image" then ["IMAGE_RESULT"] + else [] + end check(env.keys.sort == (%w[SELECTION_RESULT SELECTED] + expected_results).sort, "#{label}: incorrect gate inputs") expected_results.each do |variable| check(expression(env[variable]) == "needs.#{result_jobs.fetch(variable)}.result", "#{label}: incorrect #{variable}") @@ -61,7 +67,12 @@ def expression(value) gates[key] = guard.fetch("run") end end -check(gates.keys.sort == expected_gates.keys.sort, "Expected exactly the shard guard and both final result gates") +check(gates.keys.sort == expected_gates.keys.sort, "Expected exactly the shard guard and all three final result gates") +image_platform = workflows.fetch("hub-ci").fetch("production_image_platform") +check(image_platform["name"] == "Production image (${{ matrix.architecture }})", "Production image platform name changed") +check(Array(image_platform["needs"]) == ["changes"], "Production image platform must depend on changes") +check(image_platform.dig("strategy", "matrix", "architecture") == %w[amd64 arm64], "Production image platform must cover amd64 and arm64") +check(image_platform.dig("strategy", "fail-fast") == false, "Production image platform must not cancel other architectures on failure") check(workflows.fetch("actions-ci").fetch("test")["name"] == "Build action suite", "Build action suite name changed") states = %w[success failure cancelled skipped] + [""] @@ -70,6 +81,7 @@ def expression(value) variables = case id when "frontend_test" then %w[SHARD_RESULT INTEGRATION_RESULT] when "result" then ["TEST_RESULT"] + when "production_image" then ["IMAGE_RESULT"] else [] end combinations = variables.reduce([{}]) { |rows, variable| rows.flat_map { |row| states.map { |state| row.merge(variable => state) } } } @@ -83,6 +95,8 @@ def expression(value) results["INTEGRATION_RESULT"] == (selected == "true" ? "success" : "skipped") when "result" results["TEST_RESULT"] == (selected == "true" ? "success" : "skipped") + when "production_image" + results["IMAGE_RESULT"] == (selected == "true" ? "success" : "skipped") end env = results.merge("SELECTION_RESULT" => selection_result, "SELECTED" => selected) stdout, stderr, status = Open3.capture3(env.merge("BASH_ENV" => nil), "bash", "--noprofile", "--norc", "-e", "-c", script, chdir: root) From 0f8cb528839539deb1758b3640f0db3c5e10dad9 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 16 Oct 2026 20:47:21 +0000 Subject: [PATCH 2/3] fix(ci): scope target architecture to app image commands Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/hub-ci.yml | 10 +++++----- scripts/check-ci-workflows.rb | 2 ++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index 9d6c875..c0c365f 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -284,7 +284,7 @@ jobs: matrix: architecture: [amd64, arm64] env: - DOCKER_DEFAULT_PLATFORM: linux/${{ matrix.architecture }} + IMAGE_PLATFORM: linux/${{ matrix.architecture }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 @@ -292,13 +292,13 @@ jobs: with: platforms: arm64 - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - run: docker buildx build --platform "$DOCKER_DEFAULT_PLATFORM" --build-context learning=../packages/learning --tag replica-hub:ci --load . + - run: docker buildx build --platform "$IMAGE_PLATFORM" --build-context learning=../packages/learning --tag replica-hub:ci --load . - name: Check build-time secret cleanup - run: docker run --rm --entrypoint /bin/sh replica-hub:ci -c 'test ! -e /rails/tmp/local_secret.txt' + run: docker run --platform "$IMAGE_PLATFORM" --rm --entrypoint /bin/sh replica-hub:ci -c 'test ! -e /rails/tmp/local_secret.txt' - name: Check deployment configuration run: | umask 077 - docker run --rm replica-hub:ci \ + docker run --platform "$IMAGE_PLATFORM" --rm replica-hub:ci \ bin/replica-deployment-config generate \ --host hub.example.com \ --format dotenv > deploy/.env @@ -306,7 +306,7 @@ jobs: - name: Check production startup timeout-minutes: 3 run: | - docker run --detach --name replica-hub-ci --network none --env-file deploy/.env replica-hub:ci + docker run --platform "$IMAGE_PLATFORM" --detach --name replica-hub-ci --network none --env-file deploy/.env replica-hub:ci docker exec replica-hub-ci curl \ --fail --silent --show-error \ --retry 60 --retry-connrefused --retry-delay 2 --retry-max-time 120 --max-time 5 \ diff --git a/scripts/check-ci-workflows.rb b/scripts/check-ci-workflows.rb index 5a79958..7f0e92f 100644 --- a/scripts/check-ci-workflows.rb +++ b/scripts/check-ci-workflows.rb @@ -69,6 +69,8 @@ def expression(value) end check(gates.keys.sort == expected_gates.keys.sort, "Expected exactly the shard guard and all three final result gates") image_platform = workflows.fetch("hub-ci").fetch("production_image_platform") +check(!image_platform.fetch("env", {}).key?("DOCKER_DEFAULT_PLATFORM"), "Production image target must not change the platform of QEMU or Buildx setup") +check(image_platform.dig("env", "IMAGE_PLATFORM") == "linux/${{ matrix.architecture }}", "Production image target must follow the architecture matrix") check(image_platform["name"] == "Production image (${{ matrix.architecture }})", "Production image platform name changed") check(Array(image_platform["needs"]) == ["changes"], "Production image platform must depend on changes") check(image_platform.dig("strategy", "matrix", "architecture") == %w[amd64 arm64], "Production image platform must cover amd64 and arm64") From b2b4042638b777d9cb4c1eaca2c31a8074ddaa2e Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:54:46 +0000 Subject: [PATCH 3/3] ci(hub): build multiarchitecture images on native runners Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/hub-ci.yml | 12 +- .github/workflows/hub-release.yml | 116 ++++++++++++++++--- hub/docs/testing-and-operations.md | 4 +- scripts/check-ci-workflows.rb | 171 ++++++++++++++++++++++++++++- 4 files changed, 277 insertions(+), 26 deletions(-) diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index c0c365f..c529fa7 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -278,19 +278,19 @@ jobs: name: Production image (${{ matrix.architecture }}) needs: changes if: ${{ needs.changes.outputs.hub == 'true' }} - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} strategy: fail-fast: false matrix: - architecture: [amd64, arm64] + include: + - architecture: amd64 + runner: ubuntu-latest + - architecture: arm64 + runner: ubuntu-24.04-arm env: IMAGE_PLATFORM: linux/${{ matrix.architecture }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - if: ${{ matrix.architecture == 'arm64' }} - with: - platforms: arm64 - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - run: docker buildx build --platform "$IMAGE_PLATFORM" --build-context learning=../packages/learning --tag replica-hub:ci --load . - name: Check build-time secret cleanup diff --git a/.github/workflows/hub-release.yml b/.github/workflows/hub-release.yml index 4a90af0..1be3aa3 100644 --- a/.github/workflows/hub-release.yml +++ b/.github/workflows/hub-release.yml @@ -9,10 +9,16 @@ permissions: packages: write jobs: - publish: - name: Publish container and deployment bundle + prepare: + name: Prepare release metadata if: startsWith(github.event.release.tag_name, 'hub-v') runs-on: ubuntu-latest + outputs: + version: ${{ steps.release.outputs.version }} + stable: ${{ steps.release.outputs.stable }} + revision: ${{ steps.release.outputs.revision }} + tags: ${{ steps.metadata.outputs.tags }} + labels: ${{ steps.metadata.outputs.labels }} steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -49,16 +55,6 @@ jobs: echo "revision=$(git rev-parse HEAD)" } >> "$GITHUB_OUTPUT" - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - - - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - - uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 id: metadata with: @@ -70,18 +66,102 @@ jobs: type=semver,pattern={{major}},value=${{ steps.release.outputs.version }},enable=${{ steps.release.outputs.stable == 'true' }} type=raw,value=latest,enable=${{ steps.release.outputs.stable == 'true' }} + build: + name: Build release image (${{ matrix.architecture }}) + needs: prepare + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - architecture: amd64 + runner: ubuntu-latest + - architecture: arm64 + runner: ubuntu-24.04-arm + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.revision }} + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + id: build with: context: hub build-contexts: learning=packages/learning - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.metadata.outputs.tags }} - labels: ${{ steps.metadata.outputs.labels }} + platforms: linux/${{ matrix.architecture }} + outputs: type=image,name=ghcr.io/github/replica-hub,push-by-digest=true,name-canonical=true,push=true + labels: ${{ needs.prepare.outputs.labels }} build-args: | - REPLICA_IMAGE_REVISION=${{ steps.release.outputs.revision }} - REPLICA_IMAGE_VERSION=${{ steps.release.outputs.version }} + REPLICA_IMAGE_REVISION=${{ needs.prepare.outputs.revision }} + REPLICA_IMAGE_VERSION=${{ needs.prepare.outputs.version }} + - name: Export image digest + env: + IMAGE_DIGEST: ${{ steps.build.outputs.digest }} + run: | + if [[ ! "$IMAGE_DIGEST" =~ ^sha256:[a-f0-9]{64}$ ]]; then + echo "Build did not return a SHA-256 image digest" >&2 + exit 1 + fi + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${IMAGE_DIGEST#sha256:}" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: image-digest-${{ matrix.architecture }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish container and deployment bundle + needs: [prepare, build] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.prepare.outputs.revision }} + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + path: ${{ runner.temp }}/digests + pattern: image-digest-* + - name: Publish multiarchitecture image + env: + IMAGE_TAGS: ${{ needs.prepare.outputs.tags }} + run: | + tag_args=() + while IFS= read -r tag; do + if [[ -z "$tag" ]]; then + echo "Release metadata contains an empty image tag" >&2 + exit 1 + fi + tag_args+=(--tag "$tag") + done <<< "$IMAGE_TAGS" + refs=() + for architecture in amd64 arm64; do + files=("$RUNNER_TEMP/digests/image-digest-$architecture/"*) + if [[ "${#files[@]}" != 1 || ! -f "${files[0]}" ]]; then + echo "Expected exactly one image digest for $architecture" >&2 + exit 1 + fi + digest=$(basename "${files[0]}") + if [[ ! "$digest" =~ ^[a-f0-9]{64}$ ]]; then + echo "Invalid image digest for $architecture" >&2 + exit 1 + fi + refs+=("ghcr.io/github/replica-hub@sha256:$digest") + done + docker buildx imagetools create "${tag_args[@]}" "${refs[@]}" - name: Package deployment bundle run: | mkdir -p hub/tmp/release diff --git a/hub/docs/testing-and-operations.md b/hub/docs/testing-and-operations.md index 3545e68..33808e0 100644 --- a/hub/docs/testing-and-operations.md +++ b/hub/docs/testing-and-operations.md @@ -52,7 +52,7 @@ The production [Dockerfile](../Dockerfile) pins the official Ruby and Node image 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. -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. +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. `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. @@ -141,6 +141,8 @@ It also recovers failed GitHub webhook deliveries every minute and cleans bounde Create a GitHub release from a Hub semantic version tag in the form `hub-v..`. 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. +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. + A stable release updates the exact, minor, major, and `latest` image tags. A prerelease updates only its exact version tag. 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. diff --git a/scripts/check-ci-workflows.rb b/scripts/check-ci-workflows.rb index 7f0e92f..be38183 100644 --- a/scripts/check-ci-workflows.rb +++ b/scripts/check-ci-workflows.rb @@ -1,5 +1,7 @@ require "yaml" require "open3" +require "tmpdir" +require "fileutils" def check(condition, message) abort(message) unless condition @@ -73,7 +75,13 @@ def expression(value) check(image_platform.dig("env", "IMAGE_PLATFORM") == "linux/${{ matrix.architecture }}", "Production image target must follow the architecture matrix") check(image_platform["name"] == "Production image (${{ matrix.architecture }})", "Production image platform name changed") check(Array(image_platform["needs"]) == ["changes"], "Production image platform must depend on changes") -check(image_platform.dig("strategy", "matrix", "architecture") == %w[amd64 arm64], "Production image platform must cover amd64 and arm64") +native_matrix = [ + { "architecture" => "amd64", "runner" => "ubuntu-latest" }, + { "architecture" => "arm64", "runner" => "ubuntu-24.04-arm" } +] +check(image_platform.dig("strategy", "matrix") == { "include" => native_matrix }, "Production image platform must map amd64 and arm64 to native runners") +check(expression(image_platform["runs-on"]) == "matrix.runner", "Production image platform must use its native matrix runner") +check(image_platform.fetch("steps").none? { |step| step["uses"].to_s.start_with?("docker/setup-qemu-action@") }, "Production image checks must not use QEMU") check(image_platform.dig("strategy", "fail-fast") == false, "Production image platform must not cancel other architectures on failure") check(workflows.fetch("actions-ci").fetch("test")["name"] == "Build action suite", "Build action suite name changed") @@ -108,3 +116,164 @@ def expression(value) end end puts "CI workflow guards and wiring passed (#{checks} shell cases)." + +release = YAML.safe_load(File.read(File.join(root, ".github/workflows/hub-release.yml"))).fetch("jobs") +prepare = release.fetch("prepare") +build = release.fetch("build") +publish = release.fetch("publish") +check(expression(prepare["if"]) == "startsWith(github.event.release.tag_name, 'hub-v')", "Release preparation must require a Hub release tag") +check(Array(build["needs"]) == ["prepare"], "Release image builds must wait for preparation") +check(Array(publish["needs"]).sort == %w[build prepare], "Release publication must wait for preparation and both image builds") +[build, publish].each do |job| + check(!job.key?("if") || expression(job["if"]) == "success()", "Release builds and publication must require successful dependencies") +end +check(build.dig("strategy", "matrix") == { "include" => native_matrix }, "Release images must use both native architecture runners") +check(build.dig("strategy", "fail-fast") == false, "Release image builds must not cancel the other architecture") +check(expression(build["runs-on"]) == "matrix.runner", "Release images must use their native matrix runner") +check(release.values.flat_map { |job| job.fetch("steps") }.none? { |step| step["uses"].to_s.start_with?("docker/setup-qemu-action@") }, "Release images must not use QEMU") + +def action_step(job, action) + steps = job.fetch("steps").select { |step| step["uses"].to_s.start_with?("#{action}@") } + check(steps.length == 1, "Expected exactly one #{action} step") + steps.first +end + +check(expression(action_step(prepare, "actions/checkout").dig("with", "ref")) == "github.event.release.tag_name", "Preparation must check out the release tag") +check(expression(action_step(build, "actions/checkout").dig("with", "ref")) == "needs.prepare.outputs.revision", "Image builds must check out the immutable prepared revision") +check(expression(action_step(publish, "actions/checkout").dig("with", "ref")) == "needs.prepare.outputs.revision", "Deployment packaging must check out the immutable prepared revision") +version_step = prepare.fetch("steps").find { |step| step["name"] == "Read release version" } +check(version_step && version_step["id"], "Preparation must retain the release version script") +metadata = action_step(prepare, "docker/metadata-action") +%w[version stable revision tags labels].each do |output| + source = %w[tags labels].include?(output) ? metadata.fetch("id") : version_step.fetch("id") + check(expression(prepare.fetch("outputs").fetch(output)) == "steps.#{source}.outputs.#{output}", "Preparation must expose #{output} from its producing step") +end +check(expression(version_step.dig("env", "RELEASE_TAG")) == "github.event.release.tag_name", "Version validation must read the release tag") +check(expression(version_step.dig("env", "RELEASE_PRERELEASE")) == "github.event.release.prerelease", "Version validation must read the release prerelease flag") +action_step(build, "docker/login-action") +action_step(build, "docker/setup-buildx-action") +action_step(publish, "docker/login-action") +action_step(publish, "docker/setup-buildx-action") +image = action_step(build, "docker/build-push-action") +check(image["id"] == "build", "Release image build must expose its digest") +options = image.fetch("with") +check(options["context"] == "hub" && options["build-contexts"] == "learning=packages/learning", "Release images must build Hub with the learning context") +check(options["platforms"] == "linux/${{ matrix.architecture }}", "Each release build must target only its native architecture") +check(expression(options["labels"]) == "needs.prepare.outputs.labels", "Release images must use prepared labels") +check(!options.key?("tags"), "Architecture builds must not publish release tags") +check(options.fetch("outputs").split(",").map(&:strip).sort == %w[type=image name=ghcr.io/github/replica-hub push-by-digest=true name-canonical=true push=true].sort, "Architecture images must publish only canonical digest references") +%w[REVISION VERSION].each do |key| + check(options.fetch("build-args").lines.map(&:strip).include?("REPLICA_IMAGE_#{key}=${{ needs.prepare.outputs.#{key.downcase} }}"), "Release image must use prepared #{key.downcase}") +end +upload = action_step(build, "actions/upload-artifact").fetch("with") +check(upload["name"] == "image-digest-${{ matrix.architecture }}", "Each architecture must upload its own digest artifact") +check(upload["path"].to_s.include?("runner.temp") && upload["path"].end_with?("/digests/*"), "Digest artifacts must contain the exported digest filenames") +export_steps = build.fetch("steps").select { |step| step.fetch("env", {}).values.any? { |value| expression(value) == "steps.build.outputs.digest" } } +check(export_steps.length == 1, "Release builds must export the actual build digest") +export_step = export_steps.first +digest_variable = export_step.fetch("env").find { |_key, value| expression(value) == "steps.build.outputs.digest" }.first +download = action_step(publish, "actions/download-artifact").fetch("with") +check(download["pattern"] == "image-digest-*", "Publication must download all architecture digests") +check(download["path"].to_s.include?("runner.temp") && download["path"].end_with?("/digests"), "Publication must download digests into the runner's digest directory") +check(download["merge-multiple"] != true, "Publication must retain separate architecture artifact directories") +publish_step = publish.fetch("steps").find { |step| step["name"] == "Publish multiarchitecture image" } +check(publish_step && expression(publish_step.dig("env", "IMAGE_TAGS")) == "needs.prepare.outputs.tags", "Manifest publication must use all prepared tags") +%w[Package Upload].each do |verb| + matches = release.flat_map { |id, job| job.fetch("steps").select { |step| step["name"] == "#{verb} deployment bundle" }.map { |step| [id, step] } } + check(matches.length == 1 && matches.first.first == "publish", "The deployment bundle must be #{verb.downcase}d exactly once, in the final job") + check(publish.fetch("steps").index(matches.first.last) > publish.fetch("steps").index(publish_step), "Deployment bundle #{verb.downcase} must follow manifest publication") +end + +release_checks = 0 +Dir.mktmpdir("check-ci-workflows-", root) do |directory| + bin = File.join(directory, "bin") + FileUtils.mkdir_p(bin) + revision = "0123456789abcdef0123456789abcdef01234567" + File.write(File.join(bin, "git"), "#!/bin/bash\n[[ \"$*\" == 'rev-parse HEAD' ]] || exit 99\nprintf '%s\\n' '#{revision}'\n") + File.write(File.join(bin, "docker"), "#!/bin/bash\nprintf '%s\\0' \"$@\" '__END__' >> \"$DOCKER_ARGS\"\n") + %w[git docker].each { |name| File.chmod(0o755, File.join(bin, name)) } + shell_env = { "BASH_ENV" => nil, "PATH" => "#{bin}:/usr/bin:/bin" } + [ + ["hub-v1.2.3", "false", true], + ["hub-v0.0.0", "false", true], + ["hub-v2.0.0-rc.1", "true", true], + ["hub-v2.0.0-0.alpha-1", "true", true], + ["hub-v1.2.3", "true", false], + ["hub-v2.0.0-rc.1", "false", false], + ["hub-v01.2.3", "false", false], + ["hub-v1.2.3-rc.01", "true", false], + ["hub-v1.2.3+build", "false", false], + ["v1.2.3", "false", false], + ["hub-v1.2", "false", false], + ["hub-v1.2.3-", "true", false], + ["hub-v1.2.3", "invalid", false] + ].each do |tag, prerelease, expected| + output = File.join(directory, "version-output") + File.write(output, "") + stdout, stderr, status = Open3.capture3(shell_env.merge("RELEASE_TAG" => tag, "RELEASE_PRERELEASE" => prerelease, "GITHUB_OUTPUT" => output), "bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", version_step.fetch("run"), chdir: root) + check(status.success? == expected, "Release version #{tag}/#{prerelease}: expected #{expected}, exit #{status.exitstatus}\n#{stdout}#{stderr}") + values = File.read(output).lines.map { |line| line.strip.split("=", 2) }.to_h + check(values == (expected ? { "stable" => (prerelease == "false").to_s, "version" => tag.delete_prefix("hub-v"), "revision" => revision } : {}), "Release version #{tag}/#{prerelease}: incorrect outputs #{values.inspect}") + release_checks += 1 + end + + digests = { "amd64" => "a" * 64, "arm64" => "b" * 64 } + ["sha256:#{digests.fetch("amd64")}", "", "sha256:short", "sha512:#{'a' * 64}", "sha256:#{'g' * 64}"].each do |digest| + runner = File.join(directory, "export-runner") + FileUtils.rm_rf(runner) + stdout, stderr, status = Open3.capture3(shell_env.merge("RUNNER_TEMP" => runner, digest_variable => digest), "bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", export_step.fetch("run"), chdir: root) + expected = digest == "sha256:#{digests.fetch("amd64")}" + check(status.success? == expected, "Digest export #{digest.inspect}: expected #{expected}, exit #{status.exitstatus}\n#{stdout}#{stderr}") + files = Dir.glob(File.join(runner, "digests", "*")) + check(files.map { |file| File.basename(file) } == (expected ? [digests.fetch("amd64")] : []), "Digest export must create exactly one SHA-256 filename and reject malformed digests") + check(files.all? { |file| File.file?(file) && File.zero?(file) }, "Digest artifacts must be empty regular files") + release_checks += 1 + end + tags = %w[ghcr.io/github/replica-hub:1.2.3 ghcr.io/github/replica-hub:1.2 ghcr.io/github/replica-hub:1 ghcr.io/github/replica-hub:latest] + [ + ["stable release", {}, tags.join("\n"), true], + ["prerelease", {}, "ghcr.io/github/replica-hub:2.0.0-rc.1", true], + ["missing arm64", { "arm64" => [] }, tags.join("\n"), false], + ["missing amd64", { "amd64" => [] }, tags.join("\n"), false], + ["multiple digests", { "arm64" => ["b" * 64, "c" * 64] }, tags.join("\n"), false], + ["invalid digest", { "arm64" => ["not-a-digest"] }, tags.join("\n"), false], + ["short digest", { "amd64" => ["a" * 63] }, tags.join("\n"), false], + ["missing tags", {}, nil, false], + ["empty tags", {}, "", false], + ["leading blank tag", {}, "\n#{tags.join("\n")}", false], + ["interior blank tag", {}, tags.join("\n\n"), false], + ["trailing blank tag", {}, "#{tags.join("\n")}\n", false] + ].each do |name, overrides, image_tags, expected| + runner = File.join(directory, "runner") + FileUtils.rm_rf(runner) + digests.each do |architecture, digest| + files = overrides.fetch(architecture, [digest]) + next if files.empty? + + path = File.join(runner, "digests", "image-digest-#{architecture}") + FileUtils.mkdir_p(path) + files.each { |filename| File.write(File.join(path, filename), "") } + end + args_file = File.join(directory, "docker-args") + FileUtils.rm_f(args_file) + stdout, stderr, status = Open3.capture3(shell_env.merge("RUNNER_TEMP" => runner, "IMAGE_TAGS" => image_tags, "DOCKER_ARGS" => args_file), "bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", publish_step.fetch("run"), chdir: root) + check(status.success? == expected, "Manifest publication with #{name}: expected #{expected}, exit #{status.exitstatus}\n#{stdout}#{stderr}") + args = File.exist?(args_file) ? File.binread(args_file).split("\0") : [] + if expected + check(args.count("__END__") == 1 && args.first(3) == %w[buildx imagetools create], "Publication must create exactly one multiarchitecture manifest") + remaining = args.drop(3).reject { |argument| argument == "__END__" } + actual_tags = [] + refs = [] + until remaining.empty? + argument = remaining.shift + argument == "--tag" ? actual_tags << remaining.shift : refs << argument + end + check(actual_tags.sort == image_tags.split("\n").sort, "Manifest publication must apply exactly the prepared release tags") + check(refs.sort == digests.values.map { |digest| "ghcr.io/github/replica-hub@sha256:#{digest}" }.sort, "Manifest publication must combine exactly both immutable architecture digests") + else + check(args.empty?, "Invalid tags or architecture artifacts must fail before invoking Docker") + end + release_checks += 1 + end +end +puts "Release workflow wiring and scripts passed (#{release_checks} shell cases)."