From b8dc182b1320bb937d978d90b47e36d43f881cf3 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 7 Sep 2026 07:51:09 +0800 Subject: [PATCH 1/4] fix(release): stage immutable tag before approval --- .github/workflows/release.yml | 209 ++++++++++++------------ docs/guides/releasing.md | 30 +++- scripts/ci/test_release_build_shells.py | 122 ++++++++++++++ scripts/ci/validate_repository.py | 45 ++++- scripts/reconcile-release-tag.sh | 120 ++++++++++++++ 5 files changed, 411 insertions(+), 115 deletions(-) create mode 100755 scripts/reconcile-release-tag.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 813230451..1359c73e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,12 +283,97 @@ jobs: matrix_json: ${{ needs.preflight.outputs.matrix }} secrets: inherit + stage-tag: + name: Stage Immutable Release Tag + needs: + - preflight + - clients + - containers + if: >- + ${{ + !cancelled() && + inputs.recover_existing_tag != true && + needs.preflight.result == 'success' && + needs.clients.result == 'success' && + needs.containers.result == 'success' + }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + outputs: + tag_object: ${{ steps.release_tag.outputs.tag_object }} + + steps: + - name: Check out the trusted release controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Reject conflicting Docker version before creating the tag + shell: bash + env: + IMAGE: matrixorigin/astra:${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + if inspect_output="$(docker buildx imagetools inspect "${IMAGE}" 2>&1)"; then + echo "Docker version ${IMAGE} already exists." >&2 + echo "Choose a new version; normal publication never adopts or overwrites an existing tag." >&2 + exit 1 + elif ! grep -Eqi '(: not found|manifest unknown|name unknown|HTTP 404|status[^0-9]*404)' \ + <<< "${inspect_output}"; then + echo "Could not safely determine whether Docker version ${IMAGE} exists:" >&2 + printf '%s\n' "${inspect_output}" >&2 + exit 1 + fi + + - name: Create or validate the immutable release tag + id: release_tag + shell: bash + env: + GH_TOKEN: ${{ github.token }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} + SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} + run: | + set -euo pipefail + remote_tag_object="$( + scripts/reconcile-release-tag.sh create \ + "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ + "${GITHUB_RUN_ID}" "${DEFAULT_BRANCH}" "" + )" + echo "tag_object=${remote_tag_object}" >> "${GITHUB_OUTPUT}" + + - name: Write tag staging summary + shell: bash + run: | + { + echo "## Immutable release tag staged" + echo + echo "- Tag: \`${{ needs.preflight.outputs.source_tag }}\`" + echo "- Source: \`${{ needs.preflight.outputs.source_sha }}\`" + echo "- Tag object: \`${{ steps.release_tag.outputs.tag_object }}\`" + echo "- Owner run: \`${GITHUB_RUN_ID}\`" + echo + echo "Review the verified candidate summaries before approving publication." + } >> "${GITHUB_STEP_SUMMARY}" + publish: name: Publish Verified Release needs: - preflight - clients - containers + - stage-tag if: >- ${{ !cancelled() && @@ -297,12 +382,14 @@ jobs: ( inputs.recover_existing_tag == true && needs.clients.result == 'skipped' && - needs.containers.result == 'skipped' + needs.containers.result == 'skipped' && + needs.stage-tag.result == 'skipped' ) || ( inputs.recover_existing_tag != true && needs.clients.result == 'success' && - needs.containers.result == 'success' + needs.containers.result == 'success' && + needs.stage-tag.result == 'success' ) ) }} @@ -380,7 +467,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Resolve publication continuation state + - name: Resolve publication continuation state and validate release tag id: publication_state shell: bash env: @@ -388,31 +475,25 @@ jobs: RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} + STAGED_TAG_OBJECT: ${{ needs.stage-tag.outputs.tag_object }} + 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 - tag_object="$( - 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 + same_run=false + expected_tag_object="${RECOVERY_TAG_OBJECT}" + owner_run_id="${ORIGINAL_OWNER_RUN_ID}" + else same_run=true + expected_tag_object="${STAGED_TAG_OBJECT}" + owner_run_id="${GITHUB_RUN_ID}" fi + tag_object="$( + scripts/reconcile-release-tag.sh verify \ + "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ + "${owner_run_id}" "" "${expected_tag_object}" + )" echo "same_run=${same_run}" >> "${GITHUB_OUTPUT}" echo "tag_object=${tag_object}" >> "${GITHUB_OUTPUT}" @@ -437,86 +518,6 @@ jobs: echo "state=${state}" >> "${GITHUB_OUTPUT}" echo "release_id=${release_id}" >> "${GITHUB_OUTPUT}" - - name: Reject conflicting Docker version before creating the tag - if: >- - inputs.recover_existing_tag != true && - steps.publication_state.outputs.same_run != 'true' - shell: bash - env: - IMAGE: matrixorigin/astra:${{ needs.preflight.outputs.version }} - run: | - set -euo pipefail - if inspect_output="$(docker buildx imagetools inspect "${IMAGE}" 2>&1)"; then - echo "Docker version ${IMAGE} already exists." >&2 - echo "Choose a new version; normal publication never adopts or overwrites an existing tag." >&2 - exit 1 - elif ! grep -Eqi '(: not found|manifest unknown|name unknown|HTTP 404|status[^0-9]*404)' \ - <<< "${inspect_output}"; then - echo "Could not safely determine whether Docker version ${IMAGE} exists:" >&2 - printf '%s\n' "${inspect_output}" >&2 - exit 1 - fi - - - name: Create or validate the immutable release tag - shell: bash - env: - GH_TOKEN: ${{ github.token }} - 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 }} - 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 - fi - - name: Create or verify the immutable Docker version manifest shell: bash env: diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index bffbcd93b..b6fddc25c 100644 --- a/docs/guides/releasing.md +++ b/docs/guides/releasing.md @@ -44,11 +44,24 @@ Publication is deliberately ordered: 1. validate the exact source and version; 2. build and execute all client candidates; 3. build untagged server digests and smoke every platform; -4. create or validate the immutable annotated tag; -5. create or verify the exact Docker version manifest; +4. immediately create or validate the immutable annotated tag, before waiting + for publication approval; +5. after approval, revalidate the tag and create or verify the exact Docker + version manifest; 6. stage and publish the GitHub Release with verified client assets; 7. update stable rolling Docker tags. +Tag staging is deliberately separate from the protected publication job. +GitHub's workflow token cannot create a tag for an older default-branch commit +when a newer commit changes workflow files. Staging immediately after candidate +verification prevents a later approval delay from turning the pinned source +into an untaggable historical commit. If `main` gains workflow changes before +candidate verification finishes, staging fails without creating a tag and +tells the maintainer to start a new run from the current protected head. +Unrelated later commits do not change the selected release source and do not +force a rebuild. Re-running the same staging job is idempotent only for the +exact tag, source, and owning run. + 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 +107,9 @@ 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 tag-staging job and the +publication job. The former creates one immutable version tag after all +candidates pass; the latter creates the GitHub Release. 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 @@ -192,9 +206,11 @@ release commit; it does not modify files, create tags, or publish data. The command validates the synchronized version metadata, requires a clean checkout at the exact `origin/main` SHA, and dispatches **Release Astra** with recovery disabled. It does not create a tag locally. -3. Wait for the client and server candidate matrices to pass. -4. Review the preflight summary and approve the single `release` Environment - gate for the publication job. +3. Wait for the client and server candidate matrices to pass and for the + workflow to stage the immutable tag. If `main` gained workflow changes while + candidates were running, start a new run from the current `main` head. +4. Review the preflight and tag-staging summaries, then approve the single + `release` Environment gate for the publication job. Do not create the tag manually. The workflow creates `vX.Y.Z` as an annotated tag on the source SHA after all candidate verification succeeds. diff --git a/scripts/ci/test_release_build_shells.py b/scripts/ci/test_release_build_shells.py index 0c86f2f41..cf21a70a5 100755 --- a/scripts/ci/test_release_build_shells.py +++ b/scripts/ci/test_release_build_shells.py @@ -191,6 +191,128 @@ 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_staging_rejects_workflow_drift_before_mutation(self): + script = workflow_run_script( + ".github/workflows/release.yml", + "Create or validate the immutable release tag", + ) + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + fake_bin = fixture / "bin" + fake_bin.mkdir() + calls = fixture / "calls" + output = fixture / "output" + (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 ;; + "diff --quiet verified-source-sha new-main-sha -- .github/workflows") exit 1 ;; + *) exit 2 ;; +esac +""", + encoding="utf-8", + ) + (fake_bin / "gh").write_text( + """#!/bin/sh +set -eu +printf '%s\\n' "gh $*" >> "${ASTRA_TEST_CALLS}" +exit 99 +""", + 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": "new-main-sha", + "DEFAULT_BRANCH": "main", + "SOURCE_SHA": "verified-source-sha", + "SOURCE_TAG": "v0.2.2", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "matrixorigin/Astra", + "GITHUB_RUN_ID": "123", + "GITHUB_OUTPUT": str(output), + } + result = subprocess.run( + ["bash", "-c", script], env=env, capture_output=True, text=True + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn( + "main advanced with workflow changes during candidate verification", + result.stderr, + ) + self.assertIn("no tag was created", result.stderr) + self.assertNotIn("gh ", calls.read_text(encoding="utf-8")) + + def test_release_tag_staging_is_idempotent_for_the_same_run(self): + script = workflow_run_script( + ".github/workflows/release.yml", + "Create or validate the immutable release tag", + ) + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) + fake_bin = fixture / "bin" + fake_bin.mkdir() + calls = fixture / "calls" + output = fixture / "output" + (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), + "DEFAULT_BRANCH": "main", + "SOURCE_SHA": "verified-source-sha", + "SOURCE_TAG": "v0.2.2", + "GITHUB_SERVER_URL": "https://github.com", + "GITHUB_REPOSITORY": "matrixorigin/Astra", + "GITHUB_RUN_ID": "123", + "GITHUB_OUTPUT": str(output), + } + result = subprocess.run( + ["bash", "-c", script], env=env, capture_output=True, text=True + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual( + output.read_text(encoding="utf-8"), + "tag_object=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..06c42757e 100755 --- a/scripts/ci/validate_repository.py +++ b/scripts/ci/validate_repository.py @@ -143,6 +143,9 @@ def main() -> None: "Require Docker publication credentials", "Reject an existing Docker version before candidate builds", "Reject conflicting Docker version before creating the tag", + "stage-tag:", + "Stage Immutable Release Tag", + "needs.stage-tag.result == 'success'", "Resolve publication continuation state", "Release-Run:", "Recovery cannot adopt manual or legacy tags", @@ -152,6 +155,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 +184,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 ( + "advanced with workflow changes during candidate verification", + "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" ) + tag_stage = release_controller.find("\n stage-tag:\n") + 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 <= tag_stage < publication_job < 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 verified tag and version artifacts must be " + "staged before the GitHub Release and rolling Docker tags become public" ) for required in ( @@ -340,7 +363,19 @@ def main() -> None: f"verified candidate set ({required})" ) - publish_job = release_controller.split("\n publish:\n", 1)[1] + stage_tag_job, publish_job = release_controller.split("\n stage-tag:\n", 1)[1].split( + "\n publish:\n", 1 + ) + for required in ( + "contents: write", + "ref: ${{ github.sha }}", + "scripts/reconcile-release-tag.sh create", + ): + if required not in stage_tag_job: + errors.append( + ".github/workflows/release.yml: verified tag staging must own the " + f"current-head GitHub mutation ({required})" + ) for required in ( "!cancelled()", "needs.clients.result == 'skipped'", @@ -356,6 +391,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..a09c7b191 --- /dev/null +++ b/scripts/reconcile-release-tag.sh @@ -0,0 +1,120 @@ +#!/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 + git fetch --no-tags origin \ + "refs/heads/${default_branch}:refs/remotes/origin/${default_branch}" + if ! git merge-base --is-ancestor "${source_sha}" "${current_default_sha}"; then + echo "Release source ${source_sha} is no longer in ${default_branch} history." >&2 + echo "No tag was created." >&2 + exit 1 + fi + if git diff --quiet "${source_sha}" "${current_default_sha}" -- .github/workflows; then + : + elif [ "$?" -eq 1 ]; then + echo "${default_branch} advanced with workflow changes during candidate verification." >&2 + echo "The workflow token cannot tag the now-historical source ${source_sha}." >&2 + echo "Start a new release run from the current protected branch; no tag was created." >&2 + exit 1 + else + echo "Could not verify workflow changes between ${source_sha} and ${current_default_sha}." >&2 + echo "No tag was created." >&2 + exit 1 + fi + 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 "If ${default_branch} just gained workflow changes, restart from its current head; otherwise verify release-token and tag-ruleset permissions." >&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}" From a7a1ee3dcaa94858d20e699c70a37ee7fd6f3fd2 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 7 Sep 2026 08:03:22 +0800 Subject: [PATCH 2/4] fix(release): preserve protected publication boundary --- .github/workflows/release.yml | 198 +++++++++++------------- docs/guides/releasing.md | 41 +++-- scripts/ci/test_release_build_shells.py | 106 +++++++++---- scripts/ci/validate_repository.py | 37 ++--- scripts/reconcile-release-tag.sh | 14 +- 5 files changed, 200 insertions(+), 196 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1359c73e9..2fb636db7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -283,97 +283,12 @@ jobs: matrix_json: ${{ needs.preflight.outputs.matrix }} secrets: inherit - stage-tag: - name: Stage Immutable Release Tag - needs: - - preflight - - clients - - containers - if: >- - ${{ - !cancelled() && - inputs.recover_existing_tag != true && - needs.preflight.result == 'success' && - needs.clients.result == 'success' && - needs.containers.result == 'success' - }} - runs-on: ubuntu-latest - timeout-minutes: 15 - permissions: - contents: write - outputs: - tag_object: ${{ steps.release_tag.outputs.tag_object }} - - steps: - - name: Check out the trusted release controller - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - with: - fetch-depth: 0 - persist-credentials: false - ref: ${{ github.sha }} - - - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - - - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Reject conflicting Docker version before creating the tag - shell: bash - env: - IMAGE: matrixorigin/astra:${{ needs.preflight.outputs.version }} - run: | - set -euo pipefail - if inspect_output="$(docker buildx imagetools inspect "${IMAGE}" 2>&1)"; then - echo "Docker version ${IMAGE} already exists." >&2 - echo "Choose a new version; normal publication never adopts or overwrites an existing tag." >&2 - exit 1 - elif ! grep -Eqi '(: not found|manifest unknown|name unknown|HTTP 404|status[^0-9]*404)' \ - <<< "${inspect_output}"; then - echo "Could not safely determine whether Docker version ${IMAGE} exists:" >&2 - printf '%s\n' "${inspect_output}" >&2 - exit 1 - fi - - - name: Create or validate the immutable release tag - id: release_tag - shell: bash - env: - GH_TOKEN: ${{ github.token }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} - SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} - run: | - set -euo pipefail - remote_tag_object="$( - scripts/reconcile-release-tag.sh create \ - "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ - "${GITHUB_RUN_ID}" "${DEFAULT_BRANCH}" "" - )" - echo "tag_object=${remote_tag_object}" >> "${GITHUB_OUTPUT}" - - - name: Write tag staging summary - shell: bash - run: | - { - echo "## Immutable release tag staged" - echo - echo "- Tag: \`${{ needs.preflight.outputs.source_tag }}\`" - echo "- Source: \`${{ needs.preflight.outputs.source_sha }}\`" - echo "- Tag object: \`${{ steps.release_tag.outputs.tag_object }}\`" - echo "- Owner run: \`${GITHUB_RUN_ID}\`" - echo - echo "Review the verified candidate summaries before approving publication." - } >> "${GITHUB_STEP_SUMMARY}" - publish: name: Publish Verified Release needs: - preflight - clients - containers - - stage-tag if: >- ${{ !cancelled() && @@ -382,14 +297,12 @@ jobs: ( inputs.recover_existing_tag == true && needs.clients.result == 'skipped' && - needs.containers.result == 'skipped' && - needs.stage-tag.result == 'skipped' + needs.containers.result == 'skipped' ) || ( inputs.recover_existing_tag != true && needs.clients.result == 'success' && - needs.containers.result == 'success' && - needs.stage-tag.result == 'success' + needs.containers.result == 'success' ) ) }} @@ -398,13 +311,15 @@ jobs: timeout-minutes: 30 permissions: actions: read - contents: write + contents: read steps: - name: Require the configured release environment shell: bash env: RELEASE_ENVIRONMENT_GUARD: ${{ secrets.ASTRA_RELEASE_ENVIRONMENT_GUARD }} + RELEASE_APP_CLIENT_ID: ${{ vars.ASTRA_RELEASE_APP_CLIENT_ID }} + RELEASE_APP_PRIVATE_KEY: ${{ secrets.ASTRA_RELEASE_APP_PRIVATE_KEY }} run: | set -euo pipefail if [ "${RELEASE_ENVIRONMENT_GUARD}" != "configured" ]; then @@ -412,6 +327,23 @@ jobs: echo "Publication fails closed when the protected Environment has not been configured." >&2 exit 1 fi + missing=() + [ -n "${RELEASE_APP_CLIENT_ID}" ] || missing+=(ASTRA_RELEASE_APP_CLIENT_ID) + [ -n "${RELEASE_APP_PRIVATE_KEY}" ] || missing+=(ASTRA_RELEASE_APP_PRIVATE_KEY) + if [ "${#missing[@]}" -ne 0 ]; then + echo "The release Environment is missing: ${missing[*]}." >&2 + echo "Publication requires the repository-scoped release GitHub App." >&2 + exit 1 + fi + + - name: Mint the protected release GitHub App token + id: release_app + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.ASTRA_RELEASE_APP_CLIENT_ID }} + private-key: ${{ secrets.ASTRA_RELEASE_APP_PRIVATE_KEY }} + permission-contents: write + permission-workflows: write - name: Check out the trusted release controller uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -467,7 +399,7 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Resolve publication continuation state and validate release tag + - name: Resolve publication continuation state id: publication_state shell: bash env: @@ -475,25 +407,29 @@ jobs: RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} - STAGED_TAG_OBJECT: ${{ needs.stage-tag.outputs.tag_object }} 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 + tag_object="$( + git ls-remote origin "refs/tags/${SOURCE_TAG}" | + awk 'NR == 1 { print $1 }' + )" if [ "${RECOVER_EXISTING_TAG}" = "true" ]; then - same_run=false - expected_tag_object="${RECOVERY_TAG_OBJECT}" - owner_run_id="${ORIGINAL_OWNER_RUN_ID}" - else + 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 - expected_tag_object="${STAGED_TAG_OBJECT}" - owner_run_id="${GITHUB_RUN_ID}" + tag_object="$( + scripts/reconcile-release-tag.sh verify \ + "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ + "${GITHUB_RUN_ID}" "" "${tag_object}" + )" fi - tag_object="$( - scripts/reconcile-release-tag.sh verify \ - "${GITHUB_REPOSITORY}" "${SOURCE_TAG}" "${SOURCE_SHA}" \ - "${owner_run_id}" "" "${expected_tag_object}" - )" echo "same_run=${same_run}" >> "${GITHUB_OUTPUT}" echo "tag_object=${tag_object}" >> "${GITHUB_OUTPUT}" @@ -518,6 +454,58 @@ jobs: echo "state=${state}" >> "${GITHUB_OUTPUT}" echo "release_id=${release_id}" >> "${GITHUB_OUTPUT}" + - name: Reject conflicting Docker version before creating the tag + if: >- + inputs.recover_existing_tag != true && + steps.publication_state.outputs.same_run != 'true' + shell: bash + env: + IMAGE: matrixorigin/astra:${{ needs.preflight.outputs.version }} + run: | + set -euo pipefail + if inspect_output="$(docker buildx imagetools inspect "${IMAGE}" 2>&1)"; then + echo "Docker version ${IMAGE} already exists." >&2 + echo "Choose a new version; normal publication never adopts or overwrites an existing tag." >&2 + exit 1 + elif ! grep -Eqi '(: not found|manifest unknown|name unknown|HTTP 404|status[^0-9]*404)' \ + <<< "${inspect_output}"; then + echo "Could not safely determine whether Docker version ${IMAGE} exists:" >&2 + printf '%s\n' "${inspect_output}" >&2 + exit 1 + fi + + - name: Create or validate the immutable release tag + shell: bash + env: + GH_TOKEN: ${{ steps.release_app.outputs.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 }} + 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 + 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 env: @@ -600,7 +588,7 @@ jobs: id: stage_release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - token: ${{ github.token }} + token: ${{ steps.release_app.outputs.token }} name: Astra v${{ needs.preflight.outputs.version }} tag_name: ${{ needs.preflight.outputs.source_tag }} draft: true @@ -678,7 +666,7 @@ jobs: if: ${{ steps.existing_release.outputs.state != 'published' }} shell: bash env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app.outputs.token }} RELEASE_ID: ${{ steps.publish_release.outputs.release_id }} PRERELEASE: ${{ needs.preflight.outputs.prerelease }} PUBLISH_LATEST: ${{ needs.preflight.outputs.publish_latest }} diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index b6fddc25c..d376d297e 100644 --- a/docs/guides/releasing.md +++ b/docs/guides/releasing.md @@ -44,23 +44,17 @@ Publication is deliberately ordered: 1. validate the exact source and version; 2. build and execute all client candidates; 3. build untagged server digests and smoke every platform; -4. immediately create or validate the immutable annotated tag, before waiting - for publication approval; -5. after approval, revalidate the tag and create or verify the exact Docker - version manifest; +4. create or validate the immutable annotated tag; +5. create or verify the exact Docker version manifest; 6. stage and publish the GitHub Release with verified client assets; 7. update stable rolling Docker tags. -Tag staging is deliberately separate from the protected publication job. -GitHub's workflow token cannot create a tag for an older default-branch commit -when a newer commit changes workflow files. Staging immediately after candidate -verification prevents a later approval delay from turning the pinned source -into an untaggable historical commit. If `main` gains workflow changes before -candidate verification finishes, staging fails without creating a tag and -tells the maintainer to start a new run from the current protected head. -Unrelated later commits do not change the selected release source and do not -force a rebuild. Re-running the same staging job is idempotent only for the -exact tag, source, and owning run. +The protected publication job mints a short-lived token for the repository's +dedicated release GitHub App. This is required because the built-in workflow +token cannot create a tag for a pinned source after a newer `main` commit +changes workflow files. The App token is available only after Environment +approval, so `main` may continue moving while candidates build or wait for +approval without moving the selected source or weakening the publication gate. 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 @@ -76,6 +70,10 @@ Create a GitHub Environment named `release`: - require approval from release maintainers; - allow deployments only from `main`; - add the Environment secret `ASTRA_RELEASE_ENVIRONMENT_GUARD=configured`; +- add the Environment variable `ASTRA_RELEASE_APP_CLIENT_ID` and secret + `ASTRA_RELEASE_APP_PRIVATE_KEY` for a GitHub App installed only on Astra with + repository permissions `Contents: Read and write` and + `Workflows: Read and write`; - use this as the single publication gate after every candidate is green. Create a second Environment named `release-snapshot` for reviewed snapshot @@ -107,9 +105,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 tag-staging job and the -publication job. The former creates one immutable version tag after all -candidates pass; the latter creates the GitHub Release. +controller keeps the built-in workflow token read-only and mints the +repository-scoped App token only inside the approved publication job. Do not +store a personal access token or the App private key as a repository-level +secret. 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 @@ -206,11 +205,9 @@ release commit; it does not modify files, create tags, or publish data. The command validates the synchronized version metadata, requires a clean checkout at the exact `origin/main` SHA, and dispatches **Release Astra** with recovery disabled. It does not create a tag locally. -3. Wait for the client and server candidate matrices to pass and for the - workflow to stage the immutable tag. If `main` gained workflow changes while - candidates were running, start a new run from the current `main` head. -4. Review the preflight and tag-staging summaries, then approve the single - `release` Environment gate for the publication job. +3. Wait for the client and server candidate matrices to pass. +4. Review the preflight summary and approve the single `release` Environment + gate for the publication job. Do not create the tag manually. The workflow creates `vX.Y.Z` as an annotated tag on the source SHA after all candidate verification succeeds. diff --git a/scripts/ci/test_release_build_shells.py b/scripts/ci/test_release_build_shells.py index cf21a70a5..7625e4290 100755 --- a/scripts/ci/test_release_build_shells.py +++ b/scripts/ci/test_release_build_shells.py @@ -191,17 +191,40 @@ 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_staging_rejects_workflow_drift_before_mutation(self): + def test_release_environment_requires_the_dedicated_app(self): script = workflow_run_script( ".github/workflows/release.yml", - "Create or validate the immutable release tag", + "Require the configured release environment", ) + base_env = { + **os.environ, + "RELEASE_ENVIRONMENT_GUARD": "configured", + "RELEASE_APP_CLIENT_ID": "client-id", + "RELEASE_APP_PRIVATE_KEY": "private-key", + } + success = subprocess.run( + ["bash", "-c", script], env=base_env, capture_output=True, text=True + ) + self.assertEqual(success.returncode, 0, success.stderr) + for missing in ("RELEASE_APP_CLIENT_ID", "RELEASE_APP_PRIVATE_KEY"): + with self.subTest(missing=missing): + result = subprocess.run( + ["bash", "-c", script], + env={**base_env, missing: ""}, + capture_output=True, + text=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn(missing.removeprefix("RELEASE_"), result.stderr) + self.assertNotIn("private-key", result.stdout + result.stderr) + + def test_release_tag_creation_accepts_an_owned_historical_source(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" - output = fixture / "output" (fake_bin / "git").write_text( """#!/bin/sh set -eu @@ -213,7 +236,6 @@ def test_release_tag_staging_rejects_workflow_drift_before_mutation(self): ;; "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 ;; - "diff --quiet verified-source-sha new-main-sha -- .github/workflows") exit 1 ;; *) exit 2 ;; esac """, @@ -223,7 +245,17 @@ def test_release_tag_staging_rejects_workflow_drift_before_mutation(self): """#!/bin/sh set -eu printf '%s\\n' "gh $*" >> "${ASTRA_TEST_CALLS}" -exit 99 +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", ) @@ -234,36 +266,38 @@ def test_release_tag_staging_rejects_workflow_drift_before_mutation(self): "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", "ASTRA_TEST_CALLS": str(calls), "ASTRA_TEST_DEFAULT_SHA": "new-main-sha", - "DEFAULT_BRANCH": "main", "SOURCE_SHA": "verified-source-sha", - "SOURCE_TAG": "v0.2.2", "GITHUB_SERVER_URL": "https://github.com", - "GITHUB_REPOSITORY": "matrixorigin/Astra", - "GITHUB_RUN_ID": "123", - "GITHUB_OUTPUT": str(output), } result = subprocess.run( - ["bash", "-c", script], env=env, capture_output=True, text=True - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "main advanced with workflow changes during candidate verification", - result.stderr, + [ + str(script), + "create", + "matrixorigin/Astra", + "v0.2.2", + "verified-source-sha", + "123", + "main", + "", + ], + env=env, + capture_output=True, + text=True, ) - self.assertIn("no tag was created", result.stderr) - self.assertNotIn("gh ", calls.read_text(encoding="utf-8")) + 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("git merge-base --is-ancestor", recorded_calls) + 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) - def test_release_tag_staging_is_idempotent_for_the_same_run(self): - script = workflow_run_script( - ".github/workflows/release.yml", - "Create or validate the immutable release tag", - ) + 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" - output = fixture / "output" (fake_bin / "git").write_text( """#!/bin/sh set -eu @@ -295,22 +329,26 @@ def test_release_tag_staging_is_idempotent_for_the_same_run(self): **os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", "ASTRA_TEST_CALLS": str(calls), - "DEFAULT_BRANCH": "main", "SOURCE_SHA": "verified-source-sha", - "SOURCE_TAG": "v0.2.2", "GITHUB_SERVER_URL": "https://github.com", - "GITHUB_REPOSITORY": "matrixorigin/Astra", - "GITHUB_RUN_ID": "123", - "GITHUB_OUTPUT": str(output), } result = subprocess.run( - ["bash", "-c", script], env=env, capture_output=True, text=True + [ + 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( - output.read_text(encoding="utf-8"), - "tag_object=owned-tag-object\n", - ) + 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): diff --git a/scripts/ci/validate_repository.py b/scripts/ci/validate_repository.py index 06c42757e..b92739fd1 100755 --- a/scripts/ci/validate_repository.py +++ b/scripts/ci/validate_repository.py @@ -143,9 +143,12 @@ def main() -> None: "Require Docker publication credentials", "Reject an existing Docker version before candidate builds", "Reject conflicting Docker version before creating the tag", - "stage-tag:", - "Stage Immutable Release Tag", - "needs.stage-tag.result == 'success'", + "Mint the protected release GitHub App token", + "ASTRA_RELEASE_APP_CLIENT_ID", + "ASTRA_RELEASE_APP_PRIVATE_KEY", + "permission-contents: write", + "permission-workflows: write", + "steps.release_app.outputs.token", "Resolve publication continuation state", "Release-Run:", "Recovery cannot adopt manual or legacy tags", @@ -188,8 +191,8 @@ def main() -> None: encoding="utf-8" ) for required in ( - "advanced with workflow changes during candidate verification", - "no tag was created", + "is no longer in ${default_branch} history", + "No tag was created", "Release-Run:", 'gh api --method POST "repos/${repository}/git/tags"', 'gh api --method POST "repos/${repository}/git/refs"', @@ -204,14 +207,14 @@ def main() -> None: docker_manifest = release_controller.find( "Create or verify the immutable Docker version manifest" ) - tag_stage = release_controller.find("\n stage-tag:\n") + 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 <= tag_stage < publication_job < 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: the verified tag and version artifacts must be " - "staged 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 ( @@ -363,25 +366,15 @@ def main() -> None: f"verified candidate set ({required})" ) - stage_tag_job, publish_job = release_controller.split("\n stage-tag:\n", 1)[1].split( - "\n publish:\n", 1 - ) - for required in ( - "contents: write", - "ref: ${{ github.sha }}", - "scripts/reconcile-release-tag.sh create", - ): - if required not in stage_tag_job: - errors.append( - ".github/workflows/release.yml: verified tag staging must own the " - f"current-head GitHub mutation ({required})" - ) + publish_job = release_controller.split("\n publish:\n", 1)[1] for required in ( "!cancelled()", "needs.clients.result == 'skipped'", "needs.containers.result == 'skipped'", "inputs.recover_existing_tag != true", "ref: ${{ github.sha }}", + "contents: read", + "scripts/reconcile-release-tag.sh", ): if required not in publish_job: errors.append( diff --git a/scripts/reconcile-release-tag.sh b/scripts/reconcile-release-tag.sh index a09c7b191..afeb1a836 100755 --- a/scripts/reconcile-release-tag.sh +++ b/scripts/reconcile-release-tag.sh @@ -55,18 +55,6 @@ if [ -z "${remote_tag_object}" ]; then echo "No tag was created." >&2 exit 1 fi - if git diff --quiet "${source_sha}" "${current_default_sha}" -- .github/workflows; then - : - elif [ "$?" -eq 1 ]; then - echo "${default_branch} advanced with workflow changes during candidate verification." >&2 - echo "The workflow token cannot tag the now-historical source ${source_sha}." >&2 - echo "Start a new release run from the current protected branch; no tag was created." >&2 - exit 1 - else - echo "Could not verify workflow changes between ${source_sha} and ${current_default_sha}." >&2 - echo "No tag was created." >&2 - exit 1 - fi fi tag_message="$(printf 'Astra %s\n\n%s' "${source_tag}" "${run_marker}")" @@ -79,7 +67,7 @@ if [ -z "${remote_tag_object}" ]; then --jq .sha )"; then echo "GitHub refused to create ${source_tag}." >&2 - echo "If ${default_branch} just gained workflow changes, restart from its current head; otherwise verify release-token and tag-ruleset permissions." >&2 + echo "Verify the protected release GitHub App permissions and the release tag ruleset." >&2 exit 1 fi if ! gh api --method POST "repos/${repository}/git/refs" \ From 015692833715ce17d7fa9418fb5638f490bb6b78 Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 7 Sep 2026 08:11:50 +0800 Subject: [PATCH 3/4] fix(release): use app credentials for draft verification --- .github/workflows/release.yml | 10 +-- docs/guides/releasing.md | 10 ++- scripts/ci/test_release_build_shells.py | 82 +++++++++++++++++++++++++ 3 files changed, 94 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fb636db7..dda5292a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -437,7 +437,7 @@ jobs: id: existing_release shell: bash env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app.outputs.token }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }} SAME_RUN: ${{ steps.publication_state.outputs.same_run }} @@ -553,7 +553,7 @@ jobs: id: release_body shell: bash env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app.outputs.token }} EXISTING_RELEASE_ID: ${{ steps.existing_release.outputs.release_id }} ORIGINAL_OWNER_RUN_ID: ${{ needs.preflight.outputs.owner_run_id }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} @@ -604,7 +604,7 @@ jobs: id: publish_release shell: bash env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app.outputs.token }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} EXISTING_RELEASE_ID: ${{ steps.existing_release.outputs.release_id }} STAGED_RELEASE_ID: ${{ steps.stage_release.outputs.id }} @@ -631,7 +631,7 @@ jobs: if: ${{ steps.existing_release.outputs.state != 'published' }} shell: bash env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app.outputs.token }} RELEASE_ID: ${{ steps.publish_release.outputs.release_id }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} @@ -654,7 +654,7 @@ jobs: if: ${{ steps.existing_release.outputs.state != 'published' }} shell: bash env: - GH_TOKEN: ${{ github.token }} + GH_TOKEN: ${{ steps.release_app.outputs.token }} run: | set -euo pipefail scripts/verify_github_release_assets.py \ diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index d376d297e..af0cf451c 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. App-token +events can trigger other workflows, so this boundary must remain explicit. Publication is deliberately ordered: @@ -110,6 +110,10 @@ repository-scoped App token only inside the approved publication job. Do not store a personal access token or the App private key as a repository-level secret. +Draft lookup, body preparation, and staged body and asset verification also use +the App token: draft visibility requires push access. The read-only built-in +token remains sufficient for Actions artifacts and published release reads. + 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 maintainer actions until dedicated verification and provenance gates exist. diff --git a/scripts/ci/test_release_build_shells.py b/scripts/ci/test_release_build_shells.py index 7625e4290..ff1816ef9 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,87 @@ 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() + 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: ${{ steps.release_app.outputs.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"] == "app-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 = "app-write" if "GH_TOKEN: ${{ steps.release_app.outputs.token }}" in block 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", From 3eac1779d9f286f327fab9992bef1424b80ce61e Mon Sep 17 00:00:00 2001 From: XuPeng-SH Date: Mon, 7 Sep 2026 08:16:19 +0800 Subject: [PATCH 4/4] fix(release): reject stale candidates without app credentials --- .github/workflows/release.yml | 38 +++++-------------- docs/guides/releasing.md | 37 +++++++++--------- scripts/ci/test_release_build_shells.py | 50 +++++++++---------------- scripts/ci/validate_repository.py | 10 +---- scripts/reconcile-release-tag.sh | 12 ++---- 5 files changed, 49 insertions(+), 98 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dda5292a5..0cea0bbf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -311,15 +311,13 @@ jobs: timeout-minutes: 30 permissions: actions: read - contents: read + contents: write steps: - name: Require the configured release environment shell: bash env: RELEASE_ENVIRONMENT_GUARD: ${{ secrets.ASTRA_RELEASE_ENVIRONMENT_GUARD }} - RELEASE_APP_CLIENT_ID: ${{ vars.ASTRA_RELEASE_APP_CLIENT_ID }} - RELEASE_APP_PRIVATE_KEY: ${{ secrets.ASTRA_RELEASE_APP_PRIVATE_KEY }} run: | set -euo pipefail if [ "${RELEASE_ENVIRONMENT_GUARD}" != "configured" ]; then @@ -327,24 +325,6 @@ jobs: echo "Publication fails closed when the protected Environment has not been configured." >&2 exit 1 fi - missing=() - [ -n "${RELEASE_APP_CLIENT_ID}" ] || missing+=(ASTRA_RELEASE_APP_CLIENT_ID) - [ -n "${RELEASE_APP_PRIVATE_KEY}" ] || missing+=(ASTRA_RELEASE_APP_PRIVATE_KEY) - if [ "${#missing[@]}" -ne 0 ]; then - echo "The release Environment is missing: ${missing[*]}." >&2 - echo "Publication requires the repository-scoped release GitHub App." >&2 - exit 1 - fi - - - name: Mint the protected release GitHub App token - id: release_app - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.ASTRA_RELEASE_APP_CLIENT_ID }} - private-key: ${{ secrets.ASTRA_RELEASE_APP_PRIVATE_KEY }} - permission-contents: write - permission-workflows: write - - name: Check out the trusted release controller uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: @@ -437,7 +417,7 @@ jobs: id: existing_release shell: bash env: - GH_TOKEN: ${{ steps.release_app.outputs.token }} + GH_TOKEN: ${{ github.token }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} RECOVER_EXISTING_TAG: ${{ inputs.recover_existing_tag }} SAME_RUN: ${{ steps.publication_state.outputs.same_run }} @@ -477,7 +457,7 @@ jobs: - name: Create or validate the immutable release tag shell: bash env: - GH_TOKEN: ${{ steps.release_app.outputs.token }} + 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 }} @@ -553,7 +533,7 @@ jobs: id: release_body shell: bash env: - GH_TOKEN: ${{ steps.release_app.outputs.token }} + GH_TOKEN: ${{ github.token }} EXISTING_RELEASE_ID: ${{ steps.existing_release.outputs.release_id }} ORIGINAL_OWNER_RUN_ID: ${{ needs.preflight.outputs.owner_run_id }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} @@ -588,7 +568,7 @@ jobs: id: stage_release uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - token: ${{ steps.release_app.outputs.token }} + token: ${{ github.token }} name: Astra v${{ needs.preflight.outputs.version }} tag_name: ${{ needs.preflight.outputs.source_tag }} draft: true @@ -604,7 +584,7 @@ jobs: id: publish_release shell: bash env: - GH_TOKEN: ${{ steps.release_app.outputs.token }} + GH_TOKEN: ${{ github.token }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} EXISTING_RELEASE_ID: ${{ steps.existing_release.outputs.release_id }} STAGED_RELEASE_ID: ${{ steps.stage_release.outputs.id }} @@ -631,7 +611,7 @@ jobs: if: ${{ steps.existing_release.outputs.state != 'published' }} shell: bash env: - GH_TOKEN: ${{ steps.release_app.outputs.token }} + GH_TOKEN: ${{ github.token }} RELEASE_ID: ${{ steps.publish_release.outputs.release_id }} SOURCE_SHA: ${{ needs.preflight.outputs.source_sha }} SOURCE_TAG: ${{ needs.preflight.outputs.source_tag }} @@ -654,7 +634,7 @@ jobs: if: ${{ steps.existing_release.outputs.state != 'published' }} shell: bash env: - GH_TOKEN: ${{ steps.release_app.outputs.token }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail scripts/verify_github_release_assets.py \ @@ -666,7 +646,7 @@ jobs: if: ${{ steps.existing_release.outputs.state != 'published' }} shell: bash env: - GH_TOKEN: ${{ steps.release_app.outputs.token }} + GH_TOKEN: ${{ github.token }} RELEASE_ID: ${{ steps.publish_release.outputs.release_id }} PRERELEASE: ${{ needs.preflight.outputs.prerelease }} PUBLISH_LATEST: ${{ needs.preflight.outputs.publish_latest }} diff --git a/docs/guides/releasing.md b/docs/guides/releasing.md index af0cf451c..a7a04c0f4 100644 --- a/docs/guides/releasing.md +++ b/docs/guides/releasing.md @@ -36,8 +36,8 @@ 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. The release workflow has no -tag-push trigger; its annotated tag records the sole release owner. App-token -events can trigger other workflows, so this boundary must remain explicit. +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,12 +49,17 @@ 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 mints a short-lived token for the repository's -dedicated release GitHub App. This is required because the built-in workflow -token cannot create a tag for a pinned source after a newer `main` commit -changes workflow files. The App token is available only after Environment -approval, so `main` may continue moving while candidates build or wait for -approval without moving the selected source or weakening the publication gate. +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 @@ -70,10 +75,6 @@ Create a GitHub Environment named `release`: - require approval from release maintainers; - allow deployments only from `main`; - add the Environment secret `ASTRA_RELEASE_ENVIRONMENT_GUARD=configured`; -- add the Environment variable `ASTRA_RELEASE_APP_CLIENT_ID` and secret - `ASTRA_RELEASE_APP_PRIVATE_KEY` for a GitHub App installed only on Astra with - repository permissions `Contents: Read and write` and - `Workflows: Read and write`; - use this as the single publication gate after every candidate is green. Create a second Environment named `release-snapshot` for reviewed snapshot @@ -105,14 +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 keeps the built-in workflow token read-only and mints the -repository-scoped App token only inside the approved publication job. Do not -store a personal access token or the App private key as a repository-level -secret. - -Draft lookup, body preparation, and staged body and asset verification also use -the App token: draft visibility requires push access. The read-only built-in -token remains sufficient for Actions artifacts and published release reads. +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 ff1816ef9..9ead81940 100755 --- a/scripts/ci/test_release_build_shells.py +++ b/scripts/ci/test_release_build_shells.py @@ -29,6 +29,7 @@ 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", @@ -39,7 +40,7 @@ def test_draft_reads_use_publication_credentials(self): with self.subTest(step=name): block = workflow.split(f" - name: {name}\n", 1)[1] block = block.split(" - ", 1)[0] - self.assertIn("GH_TOKEN: ${{ steps.release_app.outputs.token }}", block) + 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() @@ -53,7 +54,7 @@ def test_draft_detection_and_body_reuse_with_restricted_visibility(self): import json, os, sys from pathlib import Path draft = Path(os.environ["ASTRA_TEST_DRAFT"]) -visible = os.environ["GH_TOKEN"] == "app-write" and draft.exists() +visible = os.environ["GH_TOKEN"] == "publication-write" and draft.exists() if "releases?" in sys.argv[2]: if visible: print("v0.2.2\\ttrue\\t42") @@ -74,7 +75,7 @@ def test_draft_detection_and_body_reuse_with_restricted_visibility(self): def run_step(name, **overrides): block = workflow.split(f" - name: {name}\n", 1)[1].split(" - ", 1)[0] - token = "app-write" if "GH_TOKEN: ${{ steps.release_app.outputs.token }}" in block else "builtin-read" + 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}, @@ -273,34 +274,7 @@ def test_client_arguments_with_and_without_features(self): "-p", "astra-edge", "--bin", "astra-edge"] self.assertEqual(result.stdout.splitlines(), expected) - def test_release_environment_requires_the_dedicated_app(self): - script = workflow_run_script( - ".github/workflows/release.yml", - "Require the configured release environment", - ) - base_env = { - **os.environ, - "RELEASE_ENVIRONMENT_GUARD": "configured", - "RELEASE_APP_CLIENT_ID": "client-id", - "RELEASE_APP_PRIVATE_KEY": "private-key", - } - success = subprocess.run( - ["bash", "-c", script], env=base_env, capture_output=True, text=True - ) - self.assertEqual(success.returncode, 0, success.stderr) - for missing in ("RELEASE_APP_CLIENT_ID", "RELEASE_APP_PRIVATE_KEY"): - with self.subTest(missing=missing): - result = subprocess.run( - ["bash", "-c", script], - env={**base_env, missing: ""}, - capture_output=True, - text=True, - ) - self.assertNotEqual(result.returncode, 0) - self.assertIn(missing.removeprefix("RELEASE_"), result.stderr) - self.assertNotIn("private-key", result.stdout + result.stderr) - - def test_release_tag_creation_accepts_an_owned_historical_source(self): + def test_release_tag_creation_requires_current_head(self): script = ROOT / "scripts/reconcile-release-tag.sh" with tempfile.TemporaryDirectory() as directory: fixture = Path(directory) @@ -347,7 +321,7 @@ def test_release_tag_creation_accepts_an_owned_historical_source(self): **os.environ, "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", "ASTRA_TEST_CALLS": str(calls), - "ASTRA_TEST_DEFAULT_SHA": "new-main-sha", + "ASTRA_TEST_DEFAULT_SHA": "verified-source-sha", "SOURCE_SHA": "verified-source-sha", "GITHUB_SERVER_URL": "https://github.com", } @@ -369,10 +343,20 @@ def test_release_tag_creation_accepts_an_owned_historical_source(self): 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("git merge-base --is-ancestor", recorded_calls) 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: diff --git a/scripts/ci/validate_repository.py b/scripts/ci/validate_repository.py index b92739fd1..28625ddae 100755 --- a/scripts/ci/validate_repository.py +++ b/scripts/ci/validate_repository.py @@ -143,12 +143,6 @@ def main() -> None: "Require Docker publication credentials", "Reject an existing Docker version before candidate builds", "Reject conflicting Docker version before creating the tag", - "Mint the protected release GitHub App token", - "ASTRA_RELEASE_APP_CLIENT_ID", - "ASTRA_RELEASE_APP_PRIVATE_KEY", - "permission-contents: write", - "permission-workflows: write", - "steps.release_app.outputs.token", "Resolve publication continuation state", "Release-Run:", "Recovery cannot adopt manual or legacy tags", @@ -191,7 +185,7 @@ def main() -> None: encoding="utf-8" ) for required in ( - "is no longer in ${default_branch} history", + "is no longer the current ${default_branch} head", "No tag was created", "Release-Run:", 'gh api --method POST "repos/${repository}/git/tags"', @@ -373,7 +367,7 @@ def main() -> None: "needs.containers.result == 'skipped'", "inputs.recover_existing_tag != true", "ref: ${{ github.sha }}", - "contents: read", + "contents: write", "scripts/reconcile-release-tag.sh", ): if required not in publish_job: diff --git a/scripts/reconcile-release-tag.sh b/scripts/reconcile-release-tag.sh index afeb1a836..547f05ebc 100755 --- a/scripts/reconcile-release-tag.sh +++ b/scripts/reconcile-release-tag.sh @@ -48,13 +48,9 @@ if [ -z "${remote_tag_object}" ]; then exit 1 fi if [ "${current_default_sha}" != "${source_sha}" ]; then - git fetch --no-tags origin \ - "refs/heads/${default_branch}:refs/remotes/origin/${default_branch}" - if ! git merge-base --is-ancestor "${source_sha}" "${current_default_sha}"; then - echo "Release source ${source_sha} is no longer in ${default_branch} history." >&2 - echo "No tag was created." >&2 - exit 1 - fi + 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}")" @@ -67,7 +63,7 @@ if [ -z "${remote_tag_object}" ]; then --jq .sha )"; then echo "GitHub refused to create ${source_tag}." >&2 - echo "Verify the protected release GitHub App permissions and the release tag ruleset." >&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" \