mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
3bb02f8f75
commit
5568d738af
2 changed files with 59 additions and 42 deletions
50
.github/workflows/docker.yml
vendored
50
.github/workflows/docker.yml
vendored
|
|
@ -220,6 +220,7 @@ jobs:
|
||||||
# tags, and that manifest is what users pull by `:tag`.
|
# tags, and that manifest is what users pull by `:tag`.
|
||||||
docker-manifest:
|
docker-manifest:
|
||||||
needs: docker-build
|
needs: docker-build
|
||||||
|
if: ${{ always() }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
strategy:
|
strategy:
|
||||||
|
|
@ -313,6 +314,11 @@ jobs:
|
||||||
echo "ERROR: no digests downloaded for variant '${{ matrix.variant.name || 'root' }}'" >&2
|
echo "ERROR: no digests downloaded for variant '${{ matrix.variant.name || 'root' }}'" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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=()
|
digest_refs=()
|
||||||
for f in "${DIGEST_DIR}"/*; do
|
for f in "${DIGEST_DIR}"/*; do
|
||||||
digest="$(basename "$f")"
|
digest="$(basename "$f")"
|
||||||
|
|
@ -382,53 +388,13 @@ jobs:
|
||||||
sleep "$sleep_for"
|
sleep "$sleep_for"
|
||||||
done
|
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
|
- 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:
|
env:
|
||||||
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
IMAGE: ${{ env.REGISTRY }}/${{ steps.image-name.outputs.image_name }}
|
||||||
VERSION: ${{ steps.version.outputs.version }}
|
VERSION: ${{ steps.version.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
# Add a unique annotation so the resulting image index manifest gets
|
# Add a unique annotation so GHCR records a fresh root package version.
|
||||||
# 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).
|
|
||||||
promoted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
promoted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
docker buildx imagetools create \
|
docker buildx imagetools create \
|
||||||
--annotation "index:io.headroom.promoted-at=${promoted_at}" \
|
--annotation "index:io.headroom.promoted-at=${promoted_at}" \
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parent.parent
|
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
|
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:
|
def test_release_workflow_publishes_both_node_packages_to_github_packages() -> None:
|
||||||
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue