diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 47d87bf70..99b5ca03b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,7 @@ jobs: candidate_run: ${{ steps.binding.outputs.run_id }} candidate_attempt: ${{ steps.binding.outputs.run_attempt }} manifest_sha256: ${{ steps.binding.outputs.manifest_sha256 }} + destination_state: ${{ steps.release_destination.outputs.state }} steps: - name: Checkout protected promotion workflow uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -247,7 +248,8 @@ jobs: echo "client_registries=${client_registries}" } >> "${GITHUB_OUTPUT}" - - name: Verify any existing draft is bound to the exact candidate + - name: Classify absent, draft, or exact published release destination + id: release_destination env: GH_TOKEN: ${{ github.token }} shell: bash @@ -269,13 +271,20 @@ jobs: --arg marker \ "registry-stack-release-candidate-v2 manifest_sha256:${{ steps.binding.outputs.manifest_sha256 }}" \ '.id == $release_id and - .draft == true and .prerelease == false and .tag_name == $tag and .name == $title and - ((.body // "") | contains($marker))' \ + ((.body // "") | contains($marker)) and + ((.draft == true and .published_at == null) or + (.draft == false and + (.published_at | type == "string" and length > 0)))' \ promotion/existing-release.json >/dev/null + state="$(jq -r 'if .draft then "draft" else "published" end' \ + promotion/existing-release.json)" + else + state=absent fi + echo "state=${state}" >> "${GITHUB_OUTPUT}" - name: Upload verified promotion input uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -288,6 +297,7 @@ jobs: stage-draft: name: Create or reconcile the bound draft + if: needs.verify.outputs.destination_state != 'published' needs: verify runs-on: ubuntu-24.04 timeout-minutes: 10 @@ -1106,9 +1116,57 @@ jobs: ((.body // "") | contains($marker))' \ published-release.json >/dev/null + closeout-published: + name: Verify an exact already-published release + if: needs.verify.outputs.destination_state == 'published' + needs: verify + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + packages: read + steps: + - name: Checkout protected promotion workflow + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.verify.outputs.promotion_revision }} + fetch-depth: 1 + persist-credentials: false + submodules: false + + - name: Install pinned public-release verifiers + shell: bash + run: | + set -euo pipefail + mkdir -p "${HOME}/.local/bin" + curl -sSfL \ + "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \ + -o "${RUNNER_TEMP}/crane.tar.gz" + echo "${CRANE_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/crane.tar.gz" \ + | sha256sum --check --strict + tar -xzf "${RUNNER_TEMP}/crane.tar.gz" \ + -C "${HOME}/.local/bin" crane + curl -sSfL \ + "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64" \ + -o "${RUNNER_TEMP}/cosign" + echo "${COSIGN_LINUX_AMD64_SHA256} ${RUNNER_TEMP}/cosign" \ + | sha256sum --check --strict + install -m 0755 "${RUNNER_TEMP}/cosign" "${HOME}/.local/bin/cosign" + echo "${HOME}/.local/bin" >> "${GITHUB_PATH}" + + - name: Verify the minimum immutable public contract + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + release/scripts/registry-release verify-public \ + --tag "${{ needs.verify.outputs.tag }}" + publish_client_npm: name: Publish exact ${{ matrix.client }} client packages to npm - if: needs.verify.outputs.client_registries == 'true' + if: >- + needs.verify.outputs.client_registries == 'true' && + needs.verify.outputs.destination_state != 'published' needs: - verify - finalize-assets @@ -1225,7 +1283,9 @@ jobs: publish_client_pypi: name: Publish exact ${{ matrix.client }} client wheels to PyPI - if: needs.verify.outputs.client_registries == 'true' + if: >- + needs.verify.outputs.client_registries == 'true' && + needs.verify.outputs.destination_state != 'published' needs: - verify - finalize-assets @@ -1334,10 +1394,12 @@ jobs: if: >- always() && needs.verify.outputs.docs_sha256 != '' && - needs.publish.result == 'success' + (needs.publish.result == 'success' || + needs.closeout-published.result == 'success') needs: - verify - publish + - closeout-published runs-on: ubuntu-24.04 timeout-minutes: 2 permissions: diff --git a/release/scripts/test_release_workflow_structure.py b/release/scripts/test_release_workflow_structure.py index e1b4152b3..49fa1afc8 100644 --- a/release/scripts/test_release_workflow_structure.py +++ b/release/scripts/test_release_workflow_structure.py @@ -666,6 +666,7 @@ def test_is_a_manual_main_workflow_with_recoverable_jobs(self) -> None: "promote-images", "finalize-assets", "publish", + "closeout-published", "publish_client_npm", "publish_client_pypi", "dispatch-docs", @@ -768,6 +769,40 @@ def test_preserves_exact_draft_binding_without_overwriting_assets(self) -> None: stage, ) + def test_fresh_retry_closes_out_only_an_exact_published_release(self) -> None: + _, document = workflow("release.yml") + classify = step_run( + document, + "verify", + "Classify absent, draft, or exact published release destination", + ) + self.assertIn(".prerelease == false", classify) + self.assertIn(".tag_name == $tag", classify) + self.assertIn(".name == $title", classify) + self.assertIn("contains($marker)", classify) + self.assertIn(".draft == true and .published_at == null", classify) + self.assertIn(".draft == false", classify) + self.assertIn('state="$(jq -r', classify) + self.assertEqual( + document["jobs"]["stage-draft"]["if"], + "needs.verify.outputs.destination_state != 'published'", + ) + closeout = document["jobs"]["closeout-published"] + self.assertEqual( + closeout["if"], + "needs.verify.outputs.destination_state == 'published'", + ) + public_verify = step_run( + document, + "closeout-published", + "Verify the minimum immutable public contract", + ) + self.assertIn("registry-release verify-public", public_verify) + self.assertIn( + "needs.closeout-published.result == 'success'", + document["jobs"]["dispatch-docs"]["if"], + ) + def test_recovers_only_the_closed_final_asset_roster(self) -> None: _, document = workflow("release.yml") stage = step_run( @@ -913,7 +948,12 @@ def test_dispatches_docs_for_current_release_candidates(self) -> None: dispatch = document["jobs"]["dispatch-docs"] self.assertIn("needs.verify.outputs.docs_sha256 != ''", dispatch["if"]) self.assertIn("needs.publish.result == 'success'", dispatch["if"]) - self.assertEqual(dispatch["needs"], ["verify", "publish"]) + self.assertIn( + "needs.closeout-published.result == 'success'", dispatch["if"] + ) + self.assertEqual( + dispatch["needs"], ["verify", "publish", "closeout-published"] + ) dispatch_run = step_run( document, "dispatch-docs", @@ -935,7 +975,12 @@ def test_promotes_exact_client_packages_with_oidc_and_retry_safety(self) -> None job["permissions"], {"actions": "read", "contents": "read", "id-token": "write"}, ) - self.assertEqual(job["if"], "needs.verify.outputs.client_registries == 'true'") + self.assertIn( + "needs.verify.outputs.client_registries == 'true'", job["if"] + ) + self.assertIn( + "needs.verify.outputs.destination_state != 'published'", job["if"] + ) self.assertEqual( job["strategy"]["matrix"]["client"], ["evidence", "relay"], @@ -952,8 +997,11 @@ def test_promotes_exact_client_packages_with_oidc_and_retry_safety(self) -> None pypi["permissions"], {"actions": "read", "contents": "read", "id-token": "write"}, ) - self.assertEqual( - pypi["if"], "needs.verify.outputs.client_registries == 'true'" + self.assertIn( + "needs.verify.outputs.client_registries == 'true'", pypi["if"] + ) + self.assertIn( + "needs.verify.outputs.destination_state != 'published'", pypi["if"] ) self.assertEqual(npm["needs"], ["verify", "finalize-assets"]) self.assertEqual(pypi["needs"], ["verify", "finalize-assets"])