Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 54 additions & 6 deletions .github/workflows/hub-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
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
- run: docker build --build-context learning=../packages/learning --tag replica-hub:ci .
- 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
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: |
docker run --rm replica-hub:ci \
umask 077
docker run --platform "$IMAGE_PLATFORM" --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 --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 \
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
116 changes: 98 additions & 18 deletions .github/workflows/hub-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/testing-and-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions hub/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions hub/docs/testing-and-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

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
Expand Down Expand Up @@ -133,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<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.

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