From bb8e81f9cd2f159c435bd817bc6d721c5f8166c4 Mon Sep 17 00:00:00 2001 From: Dr Alexander Mikhalev Date: Tue, 15 Sep 2026 18:19:28 +0100 Subject: [PATCH] fix(release): resolve draft releases by tag --- .../workflows/finalize-prebuilt-release.yml | 12 +++++----- scripts/get-release-by-tag.sh | 23 +++++++++++++++++++ tests/test_release_finalizer_contract.py | 17 +++++++++++++- 3 files changed, 45 insertions(+), 7 deletions(-) create mode 100755 scripts/get-release-by-tag.sh diff --git a/.github/workflows/finalize-prebuilt-release.yml b/.github/workflows/finalize-prebuilt-release.yml index dc2dbd7..26ce3ec 100644 --- a/.github/workflows/finalize-prebuilt-release.yml +++ b/.github/workflows/finalize-prebuilt-release.yml @@ -100,7 +100,7 @@ jobs: echo "ERROR: tag resolves to $object_sha, expected $EXPECTED_SOURCE_SHA" >&2 exit 1 } - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + release_json="$(scripts/get-release-by-tag.sh "$RELEASE_TAG")" [ "$(jq -r '.draft' <<<"$release_json")" = true ] || { echo "ERROR: release $RELEASE_TAG must remain a draft until finalization succeeds" >&2 exit 1 @@ -257,7 +257,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + release_json="$(scripts/get-release-by-tag.sh "$RELEASE_TAG")" [ "$(jq -r '.draft' <<<"$release_json")" = true ] || { echo "ERROR: release ceased to be a draft before mutation" >&2 exit 1 @@ -280,7 +280,7 @@ jobs: for asset in release-assets/*; do basename "$asset"; done printf '%s\n' "$STAGING_ASSET" } | LC_ALL=C sort > "$expected_draft" - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + release_json="$(scripts/get-release-by-tag.sh "$RELEASE_TAG")" [ "$(jq -r '.draft' <<<"$release_json")" = true ] jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual_draft" diff -u "$expected_draft" "$actual_draft" || { @@ -299,7 +299,7 @@ jobs: actual="$(mktemp)" trap 'rm -f "$expected" "$actual"' EXIT for asset in release-assets/*; do basename "$asset"; done | LC_ALL=C sort > "$expected" - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + release_json="$(scripts/get-release-by-tag.sh "$RELEASE_TAG")" [ "$(jq -r '.draft' <<<"$release_json")" = true ] jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual" if ! diff -u "$expected" "$actual"; then @@ -309,7 +309,7 @@ jobs: fi publication_state="" if ! gh release edit "$RELEASE_TAG" --draft=false; then - if ! publication_state="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")"; then + if ! publication_state="$(scripts/get-release-by-tag.sh "$RELEASE_TAG")"; then echo "ERROR: publication result is ambiguous; no recovery mutation attempted" >&2 exit 1 fi @@ -331,7 +331,7 @@ jobs: if [ -n "$publication_state" ]; then release_json="$publication_state" else - release_json="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG")" + release_json="$(scripts/get-release-by-tag.sh "$RELEASE_TAG")" fi [ "$(jq -r '.draft' <<<"$release_json")" = false ] jq -r '.assets[].name' <<<"$release_json" | LC_ALL=C sort > "$actual" diff --git a/scripts/get-release-by-tag.sh b/scripts/get-release-by-tag.sh new file mode 100755 index 0000000..8fcde22 --- /dev/null +++ b/scripts/get-release-by-tag.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Resolve both published and draft GitHub releases by their exact tag. +set -euo pipefail + +if [ "$#" -ne 1 ]; then + echo "Usage: GITHUB_REPOSITORY=owner/repo $0 " >&2 + exit 2 +fi + +repository="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is required}" +tag="$1" + +# GitHub's GET /releases/tags/{tag} endpoint excludes draft releases. Listing +# releases is the documented authenticated path that includes drafts; slurp +# keeps pagination correct for repositories with more than 100 releases. +gh api --paginate --slurp "repos/$repository/releases?per_page=100" \ + | jq -cer --arg tag "$tag" ' + [.[][] | select(.tag_name == $tag)] + | if length == 1 then .[0] + elif length == 0 then error("release tag not found: " + $tag) + else error("duplicate release tag: " + $tag) + end + ' diff --git a/tests/test_release_finalizer_contract.py b/tests/test_release_finalizer_contract.py index 959d19d..7549b63 100644 --- a/tests/test_release_finalizer_contract.py +++ b/tests/test_release_finalizer_contract.py @@ -17,6 +17,7 @@ UPDATER_SIGNATURES = ROOT / "crates/terraphim_update/src/signature.rs" VALIDATOR_PATH = ROOT / "scripts/validate-release-inputs.py" VALIDATOR_SOURCE = VALIDATOR_PATH.read_text() +RELEASE_LOOKUP = ROOT / "scripts/get-release-by-tag.sh" SPEC = importlib.util.spec_from_file_location("release_input_validator", VALIDATOR_PATH) assert SPEC is not None and SPEC.loader is not None @@ -83,7 +84,9 @@ def test_workflow_is_main_only_review_bound_and_fail_closed(self): workflow.index('gh release edit "$RELEASE_TAG" --draft=false'), ) failed_publish = workflow.index('if ! gh release edit "$RELEASE_TAG" --draft=false') - state_query = workflow.index('if ! publication_state="$(gh api', failed_publish) + state_query = workflow.index( + 'if ! publication_state="$(scripts/get-release-by-tag.sh', failed_publish + ) restore_staging = workflow.index( 'gh release upload "$RELEASE_TAG" "staging/$STAGING_ASSET" --clobber', failed_publish, @@ -92,6 +95,18 @@ def test_workflow_is_main_only_review_bound_and_fail_closed(self): self.assertIn("publication result is ambiguous; no recovery mutation attempted", workflow) self.assertIn("publication state is unknown; no recovery mutation attempted", workflow) self.assertIn("publish command failed after GitHub committed publication", workflow) + self.assertNotIn("/releases/tags/$RELEASE_TAG", workflow) + self.assertGreaterEqual( + workflow.count('scripts/get-release-by-tag.sh "$RELEASE_TAG"'), 6 + ) + + def test_draft_release_lookup_is_paginated_and_exact(self): + lookup = RELEASE_LOOKUP.read_text() + self.assertIn("gh api --paginate --slurp", lookup) + self.assertIn("select(.tag_name == $tag)", lookup) + self.assertIn('length == 1 then .[0]', lookup) + self.assertIn('error("release tag not found: " + $tag)', lookup) + self.assertIn('error("duplicate release tag: " + $tag)', lookup) def test_archive_signer_uses_the_client_trusted_primary_key(self): pinned = PINNED_KEY.read_text().strip()