diff --git a/.github/workflows/hub-ci.yml b/.github/workflows/hub-ci.yml index c1a1043..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 @@ -37,5 +43,100 @@ 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 + + # 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: + 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: + - name: Download the tested image + uses: actions/download-artifact@v4 + with: + name: hub-ci-candidate-${{ github.sha }} + path: /tmp + + - 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 }} + + # 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}"