diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 091e23928..a3d06f76c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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--*` 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, diff --git a/.release-please-config.json b/.release-please-config.json index dcf999fa9..3da8e177d 100644 --- a/.release-please-config.json +++ b/.release-please-config.json @@ -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", diff --git a/tests/test_release_workflows.py b/tests/test_release_workflows.py index 7911330ac..954e2c90b 100644 --- a/tests/test_release_workflows.py +++ b/tests/test_release_workflows.py @@ -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", [])}