From 9f68807415b0dcd7f80b7996f41cd101c24a906b Mon Sep 17 00:00:00 2001 From: Piotr Mankowski Date: Sat, 25 Jul 2026 13:33:49 -0700 Subject: [PATCH 1/3] ci: publish the image to GHCR, and verify it actually starts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hub ships only as a container but published nothing, so every consumer had to vendor this repository and build from source. Catalyst's demo stack needs the hub for exactly one thing — a running service — and had no other reason to require a checkout of it. Two tags per push to main, pointing at the same image: the commit SHA, which is immutable and what deployments should pin, and latest for convenience. The build also has to pass HUB_BUILD_REVISION. validate_config() requires a 40-character commit and rejects the Dockerfile's "unknown" default, so `docker build .` produced an image that built cleanly and then exited 1 on startup — which is what CI has been building all along. Verified both directions locally: without the argument the container exits 1 on "HUB_BUILD_REVISION must be the 40-character Git commit"; with it, /health answers in 2s and the image self-reports its revision in the standard org.opencontainers.image.revision label. docker-build now runs the image rather than only building it. A build proves the Dockerfile parses; it does not prove the packaged configuration is serviceable, and validate_config() runs at import, so starting the container and getting /health is what actually closes that gap. --- .github/workflows/hub-ci.yml | 58 +++++++++++++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index c1a1043..216a326 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -37,5 +37,61 @@ jobs: steps: - uses: actions/checkout@v4 + # HUB_BUILD_REVISION must be the real commit: validate_config() rejects + # the Dockerfile's "unknown" default, so an image built without it builds + # fine and then refuses to start. - name: Build image - run: docker build . + run: | + docker build --build-arg HUB_BUILD_REVISION="${GITHUB_SHA}" -t hub-ci-candidate . + + # Building is not evidence the image runs: validate_config() executes at + # import, so a container that starts and answers /health is what proves + # the packaged configuration is actually serviceable. + - name: Verify the image starts and serves /health + run: | + docker run -d --name hub-smoke -p 8080:8080 hub-ci-candidate + for i in $(seq 1 30); do + if curl -fsS http://localhost:8080/health >/dev/null 2>&1; then + echo "healthy after ${i}s" + docker rm -f hub-smoke >/dev/null + exit 0 + fi + sleep 1 + done + echo "image never served /health:" >&2 + docker logs hub-smoke >&2 + docker rm -f hub-smoke >/dev/null + exit 1 + + # Publish so consumers can pin an image instead of vendoring this repo and + # building from source. Catalyst's demo stack in particular has no other + # reason to require a checkout of the hub. + publish: + # Only from main: a published tag should always name reviewed, merged code, + # and pull requests (especially from forks) cannot push packages anyway. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [unit-and-contract, docker-build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Both tags point at the same image. The commit-SHA tag is the one to + # deploy: it is immutable and reproducible, which `latest` is not. + - uses: docker/build-push-action@v6 + with: + context: . + push: true + build-args: | + HUB_BUILD_REVISION=${{ github.sha }} + tags: | + ghcr.io/${{ github.repository }}:${{ github.sha }} + ghcr.io/${{ github.repository }}:latest From cc070043c94c1715b6f06d55daa3991f965aeec6 Mon Sep 17 00:00:00 2001 From: Piotr Mankowski Date: Mon, 24 Aug 2026 21:25:37 -0700 Subject: [PATCH 2/3] ci: publish the smoke-tested image by digest --- .github/workflows/hub-ci.yml | 75 +++++++++++++++++++++++++++--------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index 216a326..765e0e7 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -63,35 +63,74 @@ jobs: docker rm -f hub-smoke >/dev/null exit 1 - # Publish so consumers can pin an image instead of vendoring this repo and - # building from source. Catalyst's demo stack in particular has no other - # reason to require a checkout of the hub. + # Preserve the tested bytes for the separately permissioned publish job. + # Pull requests prove the build and startup path but do not upload or push. + - name: Export the tested image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: docker save --output /tmp/hub-ci-candidate.tar hub-ci-candidate + + - name: Transfer the tested image to the publish job + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v4 + with: + name: hub-ci-candidate-${{ github.sha }} + path: /tmp/hub-ci-candidate.tar + retention-days: 1 + if-no-files-found: error + + # Only reviewed main commits may publish. This job receives the exact image + # that passed /health; it never rebuilds the source tree. publish: - # Only from main: a published tag should always name reviewed, merged code, - # and pull requests (especially from forks) cannot push packages anyway. if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [unit-and-contract, docker-build] runs-on: ubuntu-latest permissions: contents: read packages: write + outputs: + published-image: ${{ steps.publish.outputs.image }} steps: - - uses: actions/checkout@v4 + - name: Download the tested image + uses: actions/download-artifact@v4 + with: + name: hub-ci-candidate-${{ github.sha }} + path: /tmp - - uses: docker/login-action@v3 + - name: Load and verify the tested image + run: | + docker load --input /tmp/hub-ci-candidate.tar + revision="$(docker image inspect --format \ + '{{index .Config.Labels "org.opencontainers.image.revision"}}' \ + hub-ci-candidate)" + test "${revision}" = "${GITHUB_SHA}" + + - name: Log in to GHCR + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - # Both tags point at the same image. The commit-SHA tag is the one to - # deploy: it is immutable and reproducible, which `latest` is not. - - uses: docker/build-push-action@v6 - with: - context: . - push: true - build-args: | - HUB_BUILD_REVISION=${{ github.sha }} - tags: | - ghcr.io/${{ github.repository }}:${{ github.sha }} - ghcr.io/${{ github.repository }}:latest + # Publish the exact local image that passed /health. Tags are convenient + # lookup names and can move; deployments must pin the recorded digest. + - name: Publish the tested image and record its immutable digest + id: publish + env: + IMAGE_NAME: ghcr.io/${{ github.repository }} + run: | + docker tag hub-ci-candidate "${IMAGE_NAME}:${GITHUB_SHA}" + docker tag hub-ci-candidate "${IMAGE_NAME}:latest" + docker push "${IMAGE_NAME}:${GITHUB_SHA}" + docker push "${IMAGE_NAME}:latest" + + digest="$(docker buildx imagetools inspect \ + "${IMAGE_NAME}:${GITHUB_SHA}" --format '{{.Manifest.Digest}}')" + case "${digest}" in + sha256:????????????????????????????????????????????????????????????????) ;; + *) echo "Could not resolve the published manifest digest: ${digest}" >&2; exit 1 ;; + esac + + immutable_image="${IMAGE_NAME}@${digest}" + echo "image=${immutable_image}" >> "${GITHUB_OUTPUT}" + echo "### Published tested Hub image" >> "${GITHUB_STEP_SUMMARY}" + echo "\`${immutable_image}\`" >> "${GITHUB_STEP_SUMMARY}" From 8d864258dc1d139f04b5b26625ff0ffa12f11003 Mon Sep 17 00:00:00 2001 From: Piotr Mankowski Date: Mon, 24 Aug 2026 21:32:43 -0700 Subject: [PATCH 3/3] ci: prevent stale latest publication --- .github/workflows/hub-ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index 765e0e7..ea4f6e8 100644 --- a/.github/workflows/hub-ci.yml +++ b/.github/workflows/hub-ci.yml @@ -5,6 +5,12 @@ on: push: branches: [main, "feat/**", "fix/**", harness-integration] +# A newer push supersedes any older run for the same branch. In particular, +# an older main build must not finish later and move `latest` backwards. +concurrency: + group: hub-ci-${{ github.ref }} + cancel-in-progress: true + jobs: # The fast deterministic suite: levels/config parsing, validator, prompts, # temporal, KB, roles, and the /v1 OpenAI-compat contract (test_bridge) that