Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment on lines +251 to +252

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Classify published releases before candidate expiry checks

For a fresh retry after the candidate's seven-day validity window, this new classification is never reached: the preceding artifact download requires an unexpired private artifact, and verify-candidate --promotion rejects the expired manifest at release/scripts/release_candidate.py:999. Consequently destination_state is never emitted, closeout-published is skipped, and an otherwise exact latest public release cannot be verified or have its failed docs dispatch recovered. Determine and bind the published state before the private-candidate lifetime checks, then let the read-only verify-public path establish the immutable public contract without requiring a still-live candidate.

Useful? React with 👍 / 👎.

env:
GH_TOKEN: ${{ github.token }}
shell: bash
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
56 changes: 52 additions & 4 deletions release/scripts/test_release_workflow_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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",
Expand All @@ -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"],
Expand All @@ -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"])
Expand Down