fix(ci): unjam release and Docker publishing (#2958)
## Description
Fixes two release-automation defects exposed by the 0.35.0 release:
1. Release Please grouped the single package into a PR titled `chore:
release main`, which could not be matched back to the `headroom-ai`
component/version and therefore never emitted the release event that
starts PyPI publishing.
2. Docker manifest jobs downloaded digest artifacts with overlapping
variant globs. For example, `digests-code-*` also selected code-nonroot,
code-slim, and code-slim-nonroot artifacts, yielding eight markers where
exactly two were required.
## 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
- Route the single root package through Release Please's normal
versioned-PR path.
- Preserve both `${component}` and `${version}` in generated release PR
titles.
- Download Docker amd64 and arm64 digest artifacts by exact name instead
of an overlapping variant glob.
- Add regression assertions for both Release Please title matching and
Docker artifact isolation.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
python -m pytest tests/test_release_workflows.py -q
44 passed in 2.29s
pre-commit hooks:
Sync plugin versions........................Passed
Verify Ruff version alignment...............Passed
check for merge conflicts...................Passed
ruff (legacy alias).........................Passed
ruff format.................................Passed
mypy........................................Passed
```
## Real Behavior Proof
- Environment: GitHub Actions release runs 31653073583 and 31664975515,
`main` at the 0.35.0 release merge (`93f2d7a2`).
- Exact command / steps: Inspected the Release Please rerun and each
failed Docker manifest job; enumerated the digest artifacts downloaded
by their configured patterns.
- Observed result: Release Please logged `There are untagged, merged
release PRs outstanding - aborting`. Docker's `slim`, `code`, and
`code-slim` manifest cells found 4, 8, and 4 digest markers respectively
instead of 2 because their prefix globs included related variants. The
new Docker workflow requests `digests-<variant>-amd64` and
`digests-<variant>-arm64` by exact name.
- Not tested: A synthetic release was not published because registry
versions/tags are irreversible. Both configuration invariants are
covered by regression tests, and GitHub's PR workflow validation runs
against this branch.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
N/A — workflow configuration and regression-test changes only.
## Additional Notes
- Documentation is N/A: these are internal release workflow corrections
with no user-facing command or API changes.
- The 0.35.0 Python release recovery is proceeding separately through
the existing wheel smoke-test and PyPI publication gates.
- The already-started 0.35.0 Docker run used the old workflow from tag
commit `93f2d7a2`; this PR prevents the artifact collision in subsequent
Docker runs.
This commit is contained in:
@@ -204,10 +204,10 @@ jobs:
|
||||
- name: Upload digest marker
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
# Variant + arch in the artifact name so the manifest job can
|
||||
# download with `pattern: digests-<variant>-*` to gather all
|
||||
# arches for one variant. `root` substitutes the empty-string
|
||||
# variant since GHA artifact names can't end in a hyphen.
|
||||
# Variant + arch uniquely identify the marker. The manifest job
|
||||
# downloads both architecture artifacts by exact name; a glob such
|
||||
# as `digests-code-*` would also match code-nonroot/code-slim.
|
||||
# `root` substitutes the empty-string variant.
|
||||
name: digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
if-no-files-found: error
|
||||
@@ -273,12 +273,17 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download per-arch digests for this variant
|
||||
- name: Download amd64 digest for this variant
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: digests-${{ matrix.variant.name || 'root' }}-*
|
||||
name: digests-${{ matrix.variant.name || 'root' }}-amd64
|
||||
path: ${{ runner.temp }}/digests
|
||||
|
||||
- name: Download arm64 digest for this variant
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: digests-${{ matrix.variant.name || 'root' }}-arm64
|
||||
path: ${{ runner.temp }}/digests
|
||||
merge-multiple: true
|
||||
|
||||
# Same tag rules as the pre-fan-out workflow — preserve every
|
||||
# tag flavor (semver, ref, sha-prefixed, version-suffixed,
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
"bump-patch-for-minor-pre-major": false,
|
||||
"draft": false,
|
||||
"prerelease": false,
|
||||
"separate-pull-requests": false,
|
||||
"pull-request-title-pattern": "chore: release ${version}",
|
||||
"separate-pull-requests": true,
|
||||
"pull-request-title-pattern": "chore: release${component} ${version}",
|
||||
"packages": {
|
||||
".": {
|
||||
"package-name": "headroom-ai",
|
||||
|
||||
@@ -68,6 +68,40 @@ def test_docker_latest_promotion_is_owned_by_root_manifest_cell() -> None:
|
||||
assert guard_start < manifest_script.index("exit 1", guard_start) < create_start
|
||||
|
||||
|
||||
def test_docker_manifest_downloads_exactly_one_artifact_per_architecture() -> None:
|
||||
"""Each manifest cell must download exactly its two architecture digests.
|
||||
|
||||
Keeping the variant before a trailing wildcard makes prefix-related names
|
||||
overlap: ``digests-code-*`` also selects code-nonroot, code-slim, and
|
||||
code-slim-nonroot. The 0.35.0 Docker release exposed this by downloading
|
||||
eight markers into the code manifest job instead of two.
|
||||
"""
|
||||
workflow = yaml.safe_load((ROOT / ".github" / "workflows" / "docker.yml").read_text())
|
||||
jobs = workflow["jobs"]
|
||||
build = jobs["docker-build"]
|
||||
manifest = jobs["docker-manifest"]
|
||||
upload = next(step for step in build["steps"] if step.get("name") == "Upload digest marker")
|
||||
downloads = [
|
||||
step
|
||||
for step in manifest["steps"]
|
||||
if step.get("name")
|
||||
in {
|
||||
"Download amd64 digest for this variant",
|
||||
"Download arm64 digest for this variant",
|
||||
}
|
||||
]
|
||||
|
||||
assert upload["with"]["name"] == (
|
||||
"digests-${{ matrix.variant.name || 'root' }}-${{ matrix.arch.name }}"
|
||||
)
|
||||
assert [step["with"]["name"] for step in downloads] == [
|
||||
"digests-${{ matrix.variant.name || 'root' }}-amd64",
|
||||
"digests-${{ matrix.variant.name || 'root' }}-arm64",
|
||||
]
|
||||
assert all("pattern" not in step["with"] for step in downloads)
|
||||
assert all(step["with"]["path"] == "${{ runner.temp }}/digests" for step in downloads)
|
||||
|
||||
|
||||
def test_release_workflow_publishes_both_node_packages_to_github_packages() -> None:
|
||||
content = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
|
||||
|
||||
@@ -1287,6 +1321,22 @@ def test_release_please_config_and_manifest_are_present_and_consistent() -> None
|
||||
"changelog because the bot can't find its baseline."
|
||||
)
|
||||
|
||||
# This manifest has one package. Sending it through the merge plugin
|
||||
# produces the group title `chore: release main`, which contains neither
|
||||
# the package component nor its version. On merge, release-please cannot
|
||||
# associate that title with `headroom-ai`, leaves the PR tagged
|
||||
# `autorelease: pending`, and never emits the release event that publishes
|
||||
# to PyPI. Keep the single package on the normal, versioned PR path and
|
||||
# preserve the component in the title used to match the merged PR.
|
||||
assert config.get("separate-pull-requests") is True, (
|
||||
"The single root package must bypass release-please's merge plugin; "
|
||||
"its grouped PR title is `chore: release main` and cannot be tagged."
|
||||
)
|
||||
assert config.get("pull-request-title-pattern") == ("chore: release${component} ${version}"), (
|
||||
"Release PR titles must include both component and version so "
|
||||
"release-please can match the merged PR back to headroom-ai."
|
||||
)
|
||||
|
||||
# extra-files: TypeScript SDK and npm plugin package.json files
|
||||
# files must be in lockstep with pyproject.toml.
|
||||
extra_paths = {ef["path"] for ef in root_pkg.get("extra-files", [])}
|
||||
|
||||
Reference in New Issue
Block a user