From 5568d738afb5e080d8df56e64500026996cbf025 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Wed, 12 Aug 2026 00:40:02 -0400 Subject: [PATCH] fix(ci): publish latest from the root Docker manifest (#2252) ## Description A successful root Docker image can miss `:latest` when any optional variant manifest fails. The release workflow currently gates the standalone `promote-latest` job on the aggregate `docker-manifest` matrix, so one sibling failure skips promotion even when the signed root amd64+arm64 manifest exists. This moves `:latest` promotion into the successful root manifest cell. Optional variant failures remain visible and continue to fail their jobs, but they no longer suppress the image used by `headroom install`, which defaults to `ghcr.io/headroomlabs-ai/headroom:latest`. Refs #1583 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [ ] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Publish `:latest` from the root `docker-manifest` matrix cell after its versioned multi-architecture manifest is created and signed. - Remove the aggregate `promote-latest` dependency that allowed unrelated variant failures to suppress publication. - Keep all existing root, slim, code, and nonroot variants. - Preserve native linux/amd64 and linux/arm64 manifest assembly. - Add a focused workflow-contract regression test. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_release_workflows.py -q -k "docker or latest"`) - [x] Linting passes (`uv run ruff check tests/test_release_workflows.py`) - [x] Formatting passes (`uv run ruff format --check tests/test_release_workflows.py`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text Focused checks pass: `5 passed, 34 deselected` for `uv run pytest tests/test_release_workflows.py -q -k "docker or latest"`; the full test file has one unrelated Windows `FileNotFoundError` in `test_no_native_tls_in_wheel_build_tree` because its external command is unavailable. `uv run ruff check tests/test_release_workflows.py` and `uv run ruff format tests/test_release_workflows.py --check` pass. The repository-wide format check reports eight pre-existing files outside this target. Proof report: `D:\Repos\.claude\pr-sweep\headroom-PR-TARGET-1583-PROOF.md`. ``` ## Real Behavior Proof - Environment: Windows, Python managed by `uv`, repository workflow-contract tests; production publication owned by GitHub Actions and GHCR. - Exact command / steps: run the focused release-workflow tests; after merge, inspect the next Docker release run and execute `docker buildx imagetools inspect ghcr.io/headroomlabs-ai/headroom:latest` without registry login. - Observed result: local workflow-contract proof passes for root-owned promotion and both native architecture inputs; live GHCR publication remains unverified until the next release. - Not tested: production GHCR publication before merge. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [ ] My code follows the project's style guidelines - [ ] I have performed a self-review of my code - [x] Workflow comments explain the non-obvious root-only promotion boundary - [x] Documentation outside the changelog is unchanged because the CLI image reference is already correct - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing focused tests pass locally with my changes ## Screenshots (if applicable) Not applicable. ## Additional Notes Release run https://github.com/headroomlabs-ai/headroom/actions/runs/28404020512 demonstrated the cascade: the root manifest succeeded, a nonroot manifest failed during Buildx setup, and `promote-latest` was skipped. PR CI can prove the workflow dependency and architecture-preservation contracts. GHCR availability and anonymous package visibility require the next production release plus an unauthenticated registry inspection. --- .github/workflows/docker.yml | 50 ++++++-------------------------- tests/test_release_workflows.py | 51 +++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 42 deletions(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index accab994a..091e23928 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -220,6 +220,7 @@ jobs: # tags, and that manifest is what users pull by `:tag`. docker-manifest: needs: docker-build + if: ${{ always() }} runs-on: ubuntu-24.04 timeout-minutes: 20 strategy: @@ -313,6 +314,11 @@ jobs: echo "ERROR: no digests downloaded for variant '${{ matrix.variant.name || 'root' }}'" >&2 exit 1 fi + digest_count="$(find "${DIGEST_DIR}" -maxdepth 1 -type f | wc -l)" + if [ "${digest_count}" -ne 2 ]; then + echo "ERROR: expected both architecture digests for variant '${{ matrix.variant.name || 'root' }}', found ${digest_count}" >&2 + exit 1 + fi digest_refs=() for f in "${DIGEST_DIR}"/*; do digest="$(basename "$f")" @@ -382,53 +388,13 @@ jobs: sleep "$sleep_for" done - promote-latest: - # Re-push the :latest tag pointing at the root variant *after* every - # variant manifest job has finished, so GHCR's package version - # listing (sorted by created_at) shows the root image with :latest - # at the top instead of whichever variant happened to finish last. - needs: docker-manifest - runs-on: ubuntu-24.04 - timeout-minutes: 10 - steps: - - name: Normalize image name - id: image-name - run: | - image_name="$(printf '%s' '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" - printf 'image_name=%s\n' "$image_name" >> "$GITHUB_OUTPUT" - - - name: Determine image version - id: version - env: - MANUAL_VERSION: ${{ inputs.version || github.event.inputs.version }} - RELEASE_TAG: ${{ github.event.release.tag_name }} - run: | - version="${MANUAL_VERSION#v}" - if [ -z "$version" ] && [ -n "$RELEASE_TAG" ]; then - version="${RELEASE_TAG#v}" - fi - printf 'version=%s\n' "$version" >> "$GITHUB_OUTPUT" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 - - - name: Log in to GHCR - uses: docker/login-action@v4 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - name: Re-tag root image as :latest - if: steps.version.outputs.version != '' + if: steps.manifest.outputs.index_digest != '' && matrix.variant.name == '' && steps.version.outputs.version != '' env: IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }} VERSION: ${{ steps.version.outputs.version }} run: | - # Add a unique annotation so the resulting image index manifest gets - # a new digest, which makes GHCR record a fresh package version with - # current timestamp (otherwise the existing root manifest is reused - # and stays where it was in the version listing). + # Add a unique annotation so GHCR records a fresh root package version. promoted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)" docker buildx imagetools create \ --annotation "index:io.headroom.promoted-at=${promoted_at}" \ diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 9acd4faa2..3db9d77db 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -5,6 +5,7 @@ from __future__ import annotations from pathlib import Path import pytest +import yaml ROOT = Path(__file__).resolve().parent.parent @@ -17,6 +18,56 @@ def test_docker_workflow_normalizes_repository_name_for_signing() -> None: assert "steps.image-name.outputs.image_name" in content +def test_docker_latest_promotion_is_owned_by_root_manifest_cell() -> None: + workflow = yaml.safe_load((ROOT / ".github" / "workflows" / "docker.yml").read_text()) + jobs = workflow["jobs"] + build = jobs["docker-build"] + manifest = jobs["docker-manifest"] + variants = manifest["strategy"]["matrix"]["variant"] + build_variants = build["strategy"]["matrix"]["variant"] + architectures = build["strategy"]["matrix"]["arch"] + root = next(entry for entry in variants if entry["name"] == "") + nonroot = next(entry for entry in variants if entry["name"] == "nonroot") + promotion = next( + step for step in manifest["steps"] if step["name"] == "Re-tag root image as :latest" + ) + command = promotion["run"] + + assert len(variants) == 8 + assert [entry["name"] for entry in build_variants] == [entry["name"] for entry in variants] + assert len(architectures) == 2 + assert {entry["platform"] for entry in architectures} == {"linux/amd64", "linux/arm64"} + assert root["name"] == "" + assert nonroot["name"] == "nonroot" + assert "matrix.variant.name == ''" in promotion["if"] + assert "steps.manifest.outputs.index_digest != ''" in promotion["if"] + assert "steps.version.outputs.version != ''" in promotion["if"] + assert ( + promotion["if"] + == "steps.manifest.outputs.index_digest != '' && matrix.variant.name == '' && steps.version.outputs.version != ''" + ) + assert '"${IMAGE}:latest"' in command + assert '"${IMAGE}:${VERSION}"' in command + assert "promote-latest" not in jobs + assert manifest["needs"] == "docker-build" + assert manifest["if"] == "${{ always() }}" + step_names = [step["name"] for step in manifest["steps"]] + assert step_names.index("Sign multi-arch index manifest with cosign") < step_names.index( + "Re-tag root image as :latest" + ) + manifest_script = next( + step["run"] for step in manifest["steps"] if step["name"] == "Create multi-arch manifest" + ) + assert 'digest_count="$(find "${DIGEST_DIR}" -maxdepth 1 -type f | wc -l)"' in manifest_script + assert '"${digest_count}" -ne 2' in manifest_script + assert manifest_script.index('"${digest_count}" -ne 2') < manifest_script.index( + "docker buildx imagetools create" + ) + guard_start = manifest_script.index('"${digest_count}" -ne 2') + create_start = manifest_script.index("docker buildx imagetools create") + assert guard_start < manifest_script.index("exit 1", guard_start) < create_start + + def test_release_workflow_publishes_both_node_packages_to_github_packages() -> None: content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")