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.
This commit is contained in:
@@ -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}" \
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user