diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 813230451..0cea0bbf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -325,7 +325,6 @@ jobs: echo "Publication fails closed when the protected Environment has not been configured." >&2 exit 1 fi - - name: Check out the trusted release controller uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -388,6 +387,8 @@ jobs: RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} + RECOVERY_TAG_OBJECT: ${{ needs.preflight.outputs.tag_object }} + ORIGINAL_OWNER_RUN_ID: ${{ needs.preflight.outputs.owner_run_id }} run: | set -euo pipefail same_run=false @@ -395,23 +396,19 @@ jobs: git ls-remote origin "refs/tags/${SOURCE_TAG}" | awk 'NR == 1 { print $1 }' )" - if [ -n "${tag_object}" ] && [ "${RECOVER_EXISTING_TAG}" != "true" ]; then - tag_json="$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}")" \ - || { - echo "Existing ${SOURCE_TAG} is not a release tag owned by this run." >&2 - exit 1 - } - tag_source="$(printf '%s' "${tag_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["object"]["sha"])')" - tag_message="$(printf '%s' "${tag_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["message"])')" - run_marker="Release-Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - mapfile -t run_markers < <(grep -F 'Release-Run: ' <<< "${tag_message}" || true) - if [ "${tag_source}" != "${SOURCE_SHA}" ] \ - || [ "${#run_markers[@]}" -ne 1 ] \ - || [ "${run_markers[0]}" != "${run_marker}" ]; then - echo "Release tag ${SOURCE_TAG} appeared during candidate verification and is not owned by this run." >&2 - exit 1 - fi + if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then + tag_object="$( + scripts/reconcile-release-tag.sh verify \ + "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ + "${ORIGINAL_OWNER_RUN_ID}" "" "${RECOVERY_TAG_OBJECT}" + )" + elif [ -n "${tag_object}" ]; then same_run=true + tag_object="$( + scripts/reconcile-release-tag.sh verify \ + "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ + "${GITHUB_RUN_ID}" "" "${tag_object}" + )" fi echo "same_run=${same_run}" >> "${GITHUB_OUTPUT}" echo "tag_object=${tag_object}" >> "${GITHUB_OUTPUT}" @@ -461,61 +458,33 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} - EXPECTED_TAG_OBJECT: ${{ needs.preflight.outputs.tag_object }} + RECOVERY_TAG_OBJECT: ${{ needs.preflight.outputs.tag_object }} + ORIGINAL_OWNER_RUN_ID: ${{ needs.preflight.outputs.owner_run_id }} SAME_RUN: ${{ steps.publication_state.outputs.same_run }} CURRENT_TAG_OBJECT: ${{ steps.publication_state.outputs.tag_object }} run: | set -euo pipefail - remote_tag_object="$( - git ls-remote origin "refs/tags/${SOURCE_TAG}" | - awk 'NR == 1 { print $1 }' - )" - if [ -z "${remote_tag_object}" ]; then - if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then - echo "Release tag ${SOURCE_TAG} disappeared after preflight." >&2 - exit 1 - fi - tag_message="$(printf 'Astra %s\n\nRelease-Run: %s/%s/actions/runs/%s' \ - "${SOURCE_TAG}" "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}")" - tag_object="$( - gh api --method POST "repos/${GITHUB_REPOSITORY}/git/tags" \ - -f tag="${SOURCE_TAG}" \ - -f message="${tag_message}" \ - -f object="${SOURCE_SHA}" \ - -f type=commit \ - --jq .sha - )" - gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \ - -f ref="refs/tags/${SOURCE_TAG}" \ - -f sha="${tag_object}" >/dev/null - remote_tag_object="${tag_object}" - else - if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then - if [ "${remote_tag_object}" != "${EXPECTED_TAG_OBJECT}" ]; then - echo "Release tag ${SOURCE_TAG} changed after preflight." >&2 - exit 1 - fi - elif [ "${SAME_RUN}" != "true" ] \ - || [ "${remote_tag_object}" != "${CURRENT_TAG_OBJECT}" ]; then - echo "Release tag ${SOURCE_TAG} appeared or changed during publication." >&2 - exit 1 - fi - fi - - git fetch --force origin \ - "refs/tags/${SOURCE_TAG}:refs/tags/${SOURCE_TAG}" - if [ "$(git cat-file -t "refs/tags/${SOURCE_TAG}")" != "tag" ]; then - echo "Release tag ${SOURCE_TAG} is not annotated." >&2 - exit 1 - fi - peeled_sha="$(git rev-list -n 1 "refs/tags/${SOURCE_TAG}")" - if [ "${peeled_sha}" != "${SOURCE_SHA}" ]; then - echo "Release tag ${SOURCE_TAG} points to ${peeled_sha}, expected ${SOURCE_SHA}." >&2 - exit 1 + mode=create + owner_run_id="${GITHUB_RUN_ID}" + default_branch="${DEFAULT_BRANCH}" + expected_tag_object="" + if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then + mode=verify + owner_run_id="${ORIGINAL_OWNER_RUN_ID}" + default_branch="" + expected_tag_object="${RECOVERY_TAG_OBJECT}" + elif [ "${SAME_RUN}" = "true" ]; then + mode=verify + default_branch="" + expected_tag_object="${CURRENT_TAG_OBJECT}" fi + scripts/reconcile-release-tag.sh "${mode}" \ + "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ + "${owner_run_id}" "${default_branch}" "${expected_tag_object}" >/dev/null - name: Create or verify the immutable Docker version manifest shell: bash diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index bffbcd93b..a7a04c0f4 100644 --- a/docs/guides/releasing.md +++ b/docs/guides/releasing.md @@ -35,9 +35,9 @@ therefore lets obsolete automation become the release control plane. The **Release Astra** workflow is manually dispatched from the protected default branch instead. It selects the current `main` commit, validates the complete version and release contract, builds every candidate, and creates the -annotated tag only after all candidates pass. GitHub does not start a second -workflow for a tag created with `GITHUB_TOKEN`, so one run remains the sole -release owner. +annotated tag only after all candidates pass. The release workflow has no +tag-push trigger; its annotated tag records the sole release owner. Tags created with +`GITHUB_TOKEN` do not trigger additional tag-push workflows. Publication is deliberately ordered: @@ -49,6 +49,18 @@ Publication is deliberately ordered: 6. stage and publish the GitHub Release with verified client assets; 7. update stable rolling Docker tags. +The protected publication job uses the built-in `GITHUB_TOKEN`. Immediately +before creating a new tag, it requires the selected source to still be the +current `main` head. If `main` advanced during builds or approval, publication +stops before creating a tag or versioned Docker manifest. Start a new normal +release run from current `main`; rerunning the old candidates cannot fix this. + +This check is not an atomic lock on `main`: a concurrent update can still cause +GitHub to reject tag creation. Existing-tag recovery remains available, but +does not promise to overcome GitHub workflow-permission restrictions on a +historical source. If recovery encounters that restriction, stop and inspect +the partial publication; never move the immutable tag or overwrite its assets. + The GitHub Release is not published until the exact Docker version exists. If a late step fails, rerun the failed jobs from the same Actions run so its verified artifacts are reused. The annotated tag records its owning Actions @@ -94,8 +106,10 @@ A manually created tag cannot publish anything and cannot be adopted by recovery, but it will reserve that version until an administrator removes it. Repository Actions should default to read-only permissions. The release -controller grants `contents: write` only to the publication job that creates -the tag and GitHub Release. +controller grants `contents: write` only to the approved publication job. +That same token performs draft lookup, body preparation, staged verification, +and publication; draft visibility requires push access. No GitHub App, App +private key, or personal access token is required. The source tree versions `@astra/sdk` and the Helm chart, but the workflow does not yet publish either to npm or a chart registry. Treat them as explicit diff --git a/scripts/ci/test_release_build_shells.py b/scripts/ci/test_release_build_shells.py index 0c86f2f41..9ead81940 100755 --- a/scripts/ci/test_release_build_shells.py +++ b/scripts/ci/test_release_build_shells.py @@ -2,6 +2,7 @@ """Execute release shell entrypoints with build/network commands stubbed out.""" import os +import json from pathlib import Path import re import subprocess @@ -26,6 +27,88 @@ def workflow_run_script(path, step_name): class ReleaseShellTests(unittest.TestCase): + def test_draft_reads_use_publication_credentials(self): + workflow = (ROOT / ".github/workflows/release.yml").read_text() + self.assertIn("contents: write", workflow.split("\n publish:\n", 1)[1]) + for name in ( + "Detect existing GitHub Release", + "Prepare canonical GitHub Release body", + "Resolve staged GitHub Release ID", + "Verify canonical staged GitHub Release body", + "Verify exact staged GitHub Release assets", + ): + with self.subTest(step=name): + block = workflow.split(f" - name: {name}\n", 1)[1] + block = block.split(" - ", 1)[0] + self.assertIn("GH_TOKEN: ${{ github.token }}", block) + + def test_draft_detection_and_body_reuse_with_restricted_visibility(self): + workflow = (ROOT / ".github/workflows/release.yml").read_text() + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + fake_bin = fixture / "bin" + fake_bin.mkdir() + draft = fixture / "draft.json" + gh = fake_bin / "gh" + gh.write_text('''#!/usr/bin/env python3 +import json, os, sys +from pathlib import Path +draft = Path(os.environ["ASTRA_TEST_DRAFT"]) +visible = os.environ["GH_TOKEN"] == "publication-write" and draft.exists() +if "releases?" in sys.argv[2]: + if visible: + print("v0.2.2\\ttrue\\t42") +elif visible: + print(draft.read_text()) +else: + sys.exit(1) +''', encoding="utf-8") + gh.chmod(0o755) + env = { + **os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "ASTRA_TEST_DRAFT": str(draft), "GITHUB_REPOSITORY": "matrixorigin/Astra", + "SOURCE_TAG": "v0.2.2", "SOURCE_SHA": "a" * 40, + "GITHUB_RUN_ID": "123", "ORIGINAL_OWNER_RUN_ID": "123", + "RECOVER_EXISTING_TAG": "false", "SAME_RUN": "false", + "RUNNER_TEMP": str(fixture), "GITHUB_OUTPUT": "/dev/stdout", + } + + def run_step(name, **overrides): + block = workflow.split(f" - name: {name}\n", 1)[1].split(" - ", 1)[0] + token = "publication-write" if "GH_TOKEN: ${{ github.token }}" in block and "contents: write" in workflow.split("\n publish:\n", 1)[1] else "builtin-read" + result = subprocess.run( + ["bash", "-c", workflow_run_script(".github/workflows/release.yml", name)], + cwd=ROOT, env={**env, "GH_TOKEN": token, **overrides}, + capture_output=True, text=True, + ) + return result + + first = run_step("Detect existing GitHub Release") + self.assertEqual(first.returncode, 0, first.stderr) + self.assertIn("state=none", first.stdout) + prepared = run_step("Prepare canonical GitHub Release body", EXISTING_RELEASE_ID="") + self.assertEqual(prepared.returncode, 0, prepared.stderr) + self.assertIn("generate_notes=true", prepared.stdout) + body_path = fixture / "release-body.md" + body = body_path.read_text() + "Canonical generated notes\n" + draft.write_text(json.dumps({"draft": True, "tag_name": "v0.2.2", "body": body})) + for _ in range(2): + detected = run_step("Detect existing GitHub Release", SAME_RUN="true") + self.assertEqual(detected.returncode, 0, detected.stderr) + self.assertIn("state=draft", detected.stdout) + self.assertIn("release_id=42", detected.stdout) + reused = run_step("Prepare canonical GitHub Release body", EXISTING_RELEASE_ID="42") + self.assertEqual(reused.returncode, 0, reused.stderr) + self.assertIn("generate_notes=false", reused.stdout) + self.assertEqual(body_path.read_text(), body) + verified = run_step("Verify canonical staged GitHub Release body", + RELEASE_ID="42", OWNER_RUN_ID="123", + BODY_PATH=str(body_path), GENERATED_NOTES="false") + self.assertEqual(verified.returncode, 0, verified.stderr) + conflict = run_step("Detect existing GitHub Release") + self.assertNotEqual(conflict.returncode, 0) + self.assertIn("already exists", conflict.stderr) + def run_idc_settings(self, **overrides): script = workflow_run_script( ".github/workflows/build_push_to_idc.yml", @@ -191,6 +274,149 @@ def test_client_arguments_with_and_without_features(self): "-p", "astra-edge", "--bin", "astra-edge"] self.assertEqual(result.stdout.splitlines(), expected) + def test_release_tag_creation_requires_current_head(self): + script = ROOT / "scripts/reconcile-release-tag.sh" + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + fake_bin = fixture / "bin" + fake_bin.mkdir() + calls = fixture / "calls" + (fake_bin / "git").write_text( + """#!/bin/sh +set -eu +printf '%s\\n' "git $*" >> "${ASTRA_TEST_CALLS}" +case "$*" in + "ls-remote origin refs/tags/"*) exit 0 ;; + "ls-remote origin refs/heads/main") + printf '%s\\trefs/heads/main\\n' "${ASTRA_TEST_DEFAULT_SHA}" + ;; + "fetch --no-tags origin refs/heads/main:refs/remotes/origin/main") exit 0 ;; + "merge-base --is-ancestor verified-source-sha new-main-sha") exit 0 ;; + *) exit 2 ;; +esac +""", + encoding="utf-8", + ) + (fake_bin / "gh").write_text( + """#!/bin/sh +set -eu +printf '%s\\n' "gh $*" >> "${ASTRA_TEST_CALLS}" +case "$*" in + "api --method POST repos/matrixorigin/Astra/git/tags "*) + printf '%s\\n' 'owned-tag-object' + ;; + "api --method POST repos/matrixorigin/Astra/git/refs "*) exit 0 ;; + "api repos/matrixorigin/Astra/git/tags/owned-tag-object") + printf '{"object":{"sha":"%s"},"message":"Astra v0.2.2\\\\n\\\\nRelease-Run: https://github.com/matrixorigin/Astra/actions/runs/123"}\\n' \ + 'verified-source-sha' + ;; + *) exit 2 ;; +esac +""", + encoding="utf-8", + ) + (fake_bin / "git").chmod(0o755) + (fake_bin / "gh").chmod(0o755) + env = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "ASTRA_TEST_CALLS": str(calls), + "ASTRA_TEST_DEFAULT_SHA": "verified-source-sha", + "SOURCE_SHA": "verified-source-sha", + "GITHUB_SERVER_URL": "https://github.com", + } + result = subprocess.run( + [ + str(script), + "create", + "matrixorigin/Astra", + "v0.2.2", + "verified-source-sha", + "123", + "main", + "", + ], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "owned-tag-object\n") + recorded_calls = calls.read_text(encoding="utf-8") + self.assertIn("gh api --method POST repos/matrixorigin/Astra/git/tags", recorded_calls) + self.assertIn("gh api --method POST repos/matrixorigin/Astra/git/refs", recorded_calls) + + calls.write_text("") + stale = subprocess.run( + [str(script), "create", "matrixorigin/Astra", "v0.2.2", + "verified-source-sha", "123", "main", ""], + env={**env, "ASTRA_TEST_DEFAULT_SHA": "new-main-sha"}, + capture_output=True, text=True, + ) + self.assertNotEqual(stale.returncode, 0) + self.assertIn("Start a new normal release run", stale.stderr) + self.assertNotIn("gh ", calls.read_text()) + + def test_release_tag_creation_is_idempotent_for_the_same_run(self): + script = ROOT / "scripts/reconcile-release-tag.sh" + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + fake_bin = fixture / "bin" + fake_bin.mkdir() + calls = fixture / "calls" + (fake_bin / "git").write_text( + """#!/bin/sh +set -eu +printf '%s\\n' "git $*" >> "${ASTRA_TEST_CALLS}" +case "$*" in + "ls-remote origin refs/tags/v0.2.2") + printf '%s\\trefs/tags/v0.2.2\\n' 'owned-tag-object' + ;; + *) exit 2 ;; +esac +""", + encoding="utf-8", + ) + (fake_bin / "gh").write_text( + """#!/bin/sh +set -eu +printf '%s\\n' "gh $*" >> "${ASTRA_TEST_CALLS}" +case "$*" in + *"--method POST"*) exit 99 ;; +esac +printf '{"object":{"sha":"%s"},"message":"Astra v0.2.2\\\\n\\\\nRelease-Run: https://github.com/matrixorigin/Astra/actions/runs/123"}\\n' \ + "${SOURCE_SHA}" +""", + encoding="utf-8", + ) + (fake_bin / "git").chmod(0o755) + (fake_bin / "gh").chmod(0o755) + env = { + **os.environ, + "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "ASTRA_TEST_CALLS": str(calls), + "SOURCE_SHA": "verified-source-sha", + "GITHUB_SERVER_URL": "https://github.com", + } + result = subprocess.run( + [ + str(script), + "create", + "matrixorigin/Astra", + "v0.2.2", + "verified-source-sha", + "123", + "main", + "", + ], + env=env, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(result.stdout, "owned-tag-object\n") + self.assertNotIn("--method POST", calls.read_text(encoding="utf-8")) + def test_docker_optional_mirrors_unset_and_empty(self): dockerfile = (ROOT / "Dockerfile").read_text().replace("\\\n", "") commands = re.findall(r"^RUN (set -eux;.*)$", dockerfile, re.MULTILINE) diff --git a/scripts/ci/validate_repository.py b/scripts/ci/validate_repository.py index 55eaebee8..28625ddae 100755 --- a/scripts/ci/validate_repository.py +++ b/scripts/ci/validate_repository.py @@ -152,6 +152,7 @@ def main() -> None: "release_id=${release_id}", "steps.stage_release.outputs.id", "Create or validate the immutable release tag", + "scripts/reconcile-release-tag.sh", "Prepare canonical GitHub Release body", "Stage GitHub Release and verified assets", "Verify canonical staged GitHub Release body", @@ -180,15 +181,34 @@ def main() -> None: f"release lookup contract ({required})" ) + release_tag_reconciler = Path("scripts/reconcile-release-tag.sh").read_text( + encoding="utf-8" + ) + for required in ( + "is no longer the current ${default_branch} head", + "No tag was created", + "Release-Run:", + 'gh api --method POST "repos/${repository}/git/tags"', + 'gh api --method POST "repos/${repository}/git/refs"', + "is not an annotated release tag owned by this run", + ): + if required not in release_tag_reconciler: + errors.append( + "scripts/reconcile-release-tag.sh: missing current-head or immutable " + f"ownership contract ({required})" + ) + docker_manifest = release_controller.find( "Create or verify the immutable Docker version manifest" ) + release_tag = release_controller.find("Create or validate the immutable release tag") + publication_job = release_controller.find("\n publish:\n") github_publish = release_controller.find("Publish GitHub Release") rolling_promotion = release_controller.find("Promote stable rolling Docker tags") - if not 0 <= docker_manifest < github_publish < rolling_promotion: + if not 0 <= publication_job < release_tag < docker_manifest < github_publish < rolling_promotion: errors.append( - ".github/workflows/release.yml: version artifacts must be reconciled before " - "the GitHub Release and rolling Docker tags become public" + ".github/workflows/release.yml: the protected release tag and version " + "artifacts must be reconciled before the GitHub Release and rolling tags" ) for required in ( @@ -347,6 +367,8 @@ def main() -> None: "needs.containers.result == 'skipped'", "inputs.recover_existing_tag != true", "ref: ${{ github.sha }}", + "contents: write", + "scripts/reconcile-release-tag.sh", ): if required not in publish_job: errors.append( @@ -356,6 +378,8 @@ def main() -> None: for forbidden in ( "always()", "ref: ${{ needs.preflight.outputs.source_sha }}", + 'gh api --method POST "repos/${GITHUB_REPOSITORY}/git/tags"', + 'gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs"', ): if forbidden in publish_job: errors.append( diff --git a/scripts/reconcile-release-tag.sh b/scripts/reconcile-release-tag.sh new file mode 100755 index 000000000..547f05ebc --- /dev/null +++ b/scripts/reconcile-release-tag.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Create or verify one immutable annotated release tag and its owning Actions run. + +set -euo pipefail + +mode="${1:-}" +repository="${2:-}" +source_tag="${3:-}" +source_sha="${4:-}" +owner_run_id="${5:-}" +default_branch="${6:-}" +expected_tag_object="${7:-}" + +if [ "$#" -ne 7 ] || [[ ! "${mode}" =~ ^(create|verify)$ ]] \ + || [ -z "${repository}" ] || [ -z "${source_tag}" ] \ + || [ -z "${source_sha}" ] || [ -z "${owner_run_id}" ]; then + echo "Usage: $0 " >&2 + exit 2 +fi +if [ "${mode}" = "create" ] && [ -z "${default_branch}" ]; then + echo "A default branch is required when creating ${source_tag}." >&2 + exit 2 +fi +if [ "${mode}" = "verify" ] && [ -z "${expected_tag_object}" ]; then + echo "An expected tag object is required when verifying ${source_tag}." >&2 + exit 2 +fi + +server_url="${GITHUB_SERVER_URL:-https://github.com}" +run_marker="Release-Run: ${server_url}/${repository}/actions/runs/${owner_run_id}" +remote_tag_object="$( + git ls-remote origin "refs/tags/${source_tag}" | + awk 'NR == 1 { print $1 }' +)" + +if [ -z "${remote_tag_object}" ]; then + if [ "${mode}" = "verify" ]; then + echo "Release tag ${source_tag} disappeared after candidate verification." >&2 + exit 1 + fi + + current_default_sha="$( + git ls-remote origin "refs/heads/${default_branch}" | + awk 'NR == 1 { print $1 }' + )" + if [ -z "${current_default_sha}" ]; then + echo "Could not resolve the current ${default_branch} head before creating ${source_tag}." >&2 + exit 1 + fi + if [ "${current_default_sha}" != "${source_sha}" ]; then + echo "Release source ${source_sha} is no longer the current ${default_branch} head (${current_default_sha})." >&2 + echo "No tag was created. Start a new normal release run from the current default branch; do not rerun these stale candidates." >&2 + exit 1 + fi + + tag_message="$(printf 'Astra %s\n\n%s' "${source_tag}" "${run_marker}")" + if ! remote_tag_object="$( + gh api --method POST "repos/${repository}/git/tags" \ + -f tag="${source_tag}" \ + -f message="${tag_message}" \ + -f object="${source_sha}" \ + -f type=commit \ + --jq .sha + )"; then + echo "GitHub refused to create ${source_tag}." >&2 + echo "Check the publication token permissions and tag ruleset. If the default branch advanced, start a new normal release run." >&2 + exit 1 + fi + if ! gh api --method POST "repos/${repository}/git/refs" \ + -f ref="refs/tags/${source_tag}" \ + -f sha="${remote_tag_object}" >/dev/null; then + observed_tag_object="$( + git ls-remote origin "refs/tags/${source_tag}" | + awk 'NR == 1 { print $1 }' + )" + if [ "${observed_tag_object}" != "${remote_tag_object}" ]; then + echo "Could not create immutable release ref ${source_tag}." >&2 + exit 1 + fi + fi +elif [ "${mode}" = "verify" ] && [ "${remote_tag_object}" != "${expected_tag_object}" ]; then + echo "Release tag ${source_tag} changed after it was verified." >&2 + exit 1 +fi + +tag_json="$(gh api "repos/${repository}/git/tags/${remote_tag_object}")" || { + echo "Existing ${source_tag} is not an annotated release tag owned by this run." >&2 + exit 1 +} +TAG_JSON="${tag_json}" python3 - "${source_sha}" "${run_marker}" <<'PY' +import json +import os +import sys + +tag = json.loads(os.environ["TAG_JSON"]) +expected_source, expected_marker = sys.argv[1:] +message = tag.get("message", "") +markers = [line for line in message.splitlines() if "Release-Run: " in line] +if tag.get("object", {}).get("sha") != expected_source or markers != [expected_marker]: + print("Release tag is not owned by this run and source.", file=sys.stderr) + raise SystemExit(1) +PY + +printf '%s\n' "${remote_tag_object}"