From a34f3c752bdbe3cda6974dcc35e60051013f9692 Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:20:44 +0100 Subject: [PATCH] Keep convergence evidence off sealed releases Change-source: pulse-maintainer --- .../workflows/promote-private-pro-runtime.yml | 54 +++++- .github/workflows/release-convergence.yml | 73 +------- .github/workflows/update-demo-server.yml | 20 +-- .../subsystems/deployment-installability.md | 13 +- .../installtests/build_release_assets_test.go | 3 +- .../customer_promotion_lease.sh | 73 +++++++- .../release_promotion_policy_test.py | 160 +++++++++++++++--- 7 files changed, 290 insertions(+), 106 deletions(-) diff --git a/.github/workflows/promote-private-pro-runtime.yml b/.github/workflows/promote-private-pro-runtime.yml index 26f7d2eab..df895459c 100644 --- a/.github/workflows/promote-private-pro-runtime.yml +++ b/.github/workflows/promote-private-pro-runtime.yml @@ -29,11 +29,11 @@ on: required: true type: string pulse_owner_asset_name: - description: "Unique immutable convergence-owner evidence asset." + description: "Owner evidence path in the exact Pulse lease commit." required: true type: string pulse_owner_asset_sha256: - description: "SHA-256 digest of the immutable convergence-owner evidence." + description: "SHA-256 digest of the commit-bound owner evidence." required: true type: string @@ -67,6 +67,56 @@ jobs: echo "::error::Private Pro staging did not return an R2 prefix." exit 1 fi + if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(alpha|beta|rc)\.[1-9][0-9]*)?$ ]] || \ + [[ ! "${PULSE_LEASE_SHA}" =~ ^[0-9a-f]{40}$ ]] || \ + [[ ! "${PULSE_CONVERGENCE_RUN_ID}" =~ ^[0-9]+$ ]] || \ + [[ ! "${PULSE_OWNER_ASSET_NAME}" =~ ^release-convergence-owner-${PULSE_CONVERGENCE_RUN_ID}-[1-9][0-9]*\.json$ ]] || \ + [[ ! "${PULSE_OWNER_ASSET_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::Paid-runtime promotion requires an exact release, convergence owner, and lease." + exit 1 + fi + + release_json="$(mktemp)" + marker="$(mktemp)" + owner_record="$(mktemp)" + cleanup_owner_proof() { + rm -f "${release_json}" "${marker}" "${owner_record}" + } + trap cleanup_owner_proof EXIT + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" > "${release_json}" + jq -e --arg tag "${TAG}" \ + '.tag_name == $tag and .draft == false and .immutable == true and (.published_at | type == "string" and length > 0)' \ + "${release_json}" >/dev/null + observed_lease_sha="$( + gh api \ + "repos/${GITHUB_REPOSITORY}/git/ref/heads/release-customer-promotion-lock" \ + --jq '.object.sha' + )" + if [[ "${observed_lease_sha}" != "${PULSE_LEASE_SHA}" ]]; then + echo "::error::Paid-runtime lease is ${observed_lease_sha:-absent}, expected ${PULSE_LEASE_SHA}." + exit 1 + fi + curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \ + -o "${marker}" \ + "https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}/release-activation.json" + curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \ + -o "${owner_record}" \ + "https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${PULSE_LEASE_SHA}/${PULSE_OWNER_ASSET_NAME}" + printf '%s %s\n' "${PULSE_OWNER_ASSET_SHA256}" "${owner_record}" | sha256sum --check -- + activation_owner_run_id="$(jq -r '.convergence_run_id' "${marker}")" + activation_marker_sha256="$(sha256sum "${marker}" | awk '{print $1}')" + jq -e \ + --arg tag "${TAG}" \ + --arg r2_prefix "${R2_PREFIX}" \ + --arg activation_owner_run_id "${activation_owner_run_id}" \ + --arg activation_marker_sha256 "${activation_marker_sha256}" \ + --arg convergence_run_id "${PULSE_CONVERGENCE_RUN_ID}" \ + --arg owner_asset_name "${PULSE_OWNER_ASSET_NAME}" \ + '.schema_version == 2 and .tag == $tag and .r2_prefix == $r2_prefix and .activation_owner_run_id == $activation_owner_run_id and .activation_marker_sha256 == $activation_marker_sha256 and .convergence_run_id == $convergence_run_id and .owner_asset_name == $owner_asset_name' \ + "${owner_record}" >/dev/null + cleanup_owner_proof + trap - EXIT + echo "[OK] Paid-runtime mutation is bound to immutable ${TAG} convergence run ${PULSE_CONVERGENCE_RUN_ID}." wait_for_workflow() { local repo="$1" diff --git a/.github/workflows/release-convergence.yml b/.github/workflows/release-convergence.yml index e51e203f3..94571b990 100644 --- a/.github/workflows/release-convergence.yml +++ b/.github/workflows/release-convergence.yml @@ -207,8 +207,8 @@ jobs: lock_sha: ${{ steps.acquire.outputs.lock_sha }} superseded: ${{ steps.admit.outputs.superseded }} desired_tag: ${{ steps.admit.outputs.desired_tag }} - owner_asset_name: ${{ steps.owner.outputs.owner_asset_name }} - owner_asset_sha256: ${{ steps.owner.outputs.owner_asset_sha256 }} + owner_asset_name: ${{ steps.acquire.outputs.owner_asset_name }} + owner_asset_sha256: ${{ steps.acquire.outputs.owner_asset_sha256 }} server_image_digest: ${{ needs.await_activation_commit.outputs.server_image_digest }} control_plane_image_digest: ${{ needs.await_activation_commit.outputs.control_plane_image_digest }} helm_chart_digest: ${{ needs.await_activation_commit.outputs.helm_chart_digest }} @@ -224,6 +224,12 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ inputs.tag }} + TARGET_COMMITISH: ${{ inputs.target_commitish }} + RELEASE_ID: ${{ inputs.release_id }} + SOURCE_RELEASE_RUN_ID: ${{ inputs.source_release_run_id }} + R2_PREFIX: ${{ inputs.r2_prefix }} + ACTIVATION_OWNER_RUN_ID: ${{ needs.await_activation_commit.outputs.activation_owner_run_id }} + ACTIVATION_MARKER_SHA256: ${{ needs.await_activation_commit.outputs.activation_marker_sha256 }} run: | set -euo pipefail scripts/release_control/customer_promotion_lease.sh acquire "${TAG}" @@ -272,69 +278,6 @@ jobs: echo "[OK] ${TAG} is the monotonic ${IS_PRERELEASE} channel convergence target." fi - - name: Bind this lease as the active convergence successor - id: owner - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ inputs.tag }} - TARGET_COMMITISH: ${{ inputs.target_commitish }} - RELEASE_ID: ${{ inputs.release_id }} - SOURCE_RELEASE_RUN_ID: ${{ inputs.source_release_run_id }} - R2_PREFIX: ${{ inputs.r2_prefix }} - ACTIVATION_OWNER_RUN_ID: ${{ needs.await_activation_commit.outputs.activation_owner_run_id }} - ACTIVATION_MARKER_SHA256: ${{ needs.await_activation_commit.outputs.activation_marker_sha256 }} - LEASE_SHA: ${{ steps.acquire.outputs.lock_sha }} - run: | - set -euo pipefail - owner_dir="$(mktemp -d)" - verified_record="$(mktemp)" - cleanup() { rm -rf "${owner_dir}"; rm -f "${verified_record}"; } - trap cleanup EXIT - owner_asset_name="release-convergence-owner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${LEASE_SHA}.json" - owner_record="${owner_dir}/${owner_asset_name}" - jq -n \ - --arg tag "${TAG}" \ - --arg target_commitish "${TARGET_COMMITISH}" \ - --arg release_id "${RELEASE_ID}" \ - --arg source_release_run_id "${SOURCE_RELEASE_RUN_ID}" \ - --arg r2_prefix "${R2_PREFIX}" \ - --arg activation_owner_run_id "${ACTIVATION_OWNER_RUN_ID}" \ - --arg activation_marker_sha256 "${ACTIVATION_MARKER_SHA256}" \ - --arg convergence_run_id "${GITHUB_RUN_ID}" \ - --arg lease_sha "${LEASE_SHA}" \ - --arg owner_asset_name "${owner_asset_name}" \ - '{ - schema_version: 1, - tag: $tag, - target_commitish: $target_commitish, - release_id: $release_id, - source_release_run_id: $source_release_run_id, - r2_prefix: $r2_prefix, - activation_owner_run_id: $activation_owner_run_id, - activation_marker_sha256: $activation_marker_sha256, - convergence_run_id: $convergence_run_id, - lease_sha: $lease_sha, - owner_asset_name: $owner_asset_name - }' > "${owner_record}" - owner_asset_sha256="$(sha256sum "${owner_record}" | awk '{print $1}')" - echo "owner_asset_name=${owner_asset_name}" >> "$GITHUB_OUTPUT" - echo "owner_asset_sha256=${owner_asset_sha256}" >> "$GITHUB_OUTPUT" - gh release upload "${TAG}" "${owner_record}" - curl -fsSL --retry 8 --retry-delay 2 --retry-all-errors \ - -o "${verified_record}" \ - "https://github.com/${{ github.repository }}/releases/download/${TAG}/${owner_asset_name}" - printf '%s %s\n' "${owner_asset_sha256}" "${verified_record}" | sha256sum --check -- - jq -e \ - --arg tag "${TAG}" \ - --arg activation_owner_run_id "${ACTIVATION_OWNER_RUN_ID}" \ - --arg activation_marker_sha256 "${ACTIVATION_MARKER_SHA256}" \ - --arg convergence_run_id "${GITHUB_RUN_ID}" \ - --arg lease_sha "${LEASE_SHA}" \ - --arg owner_asset_name "${owner_asset_name}" \ - '.schema_version == 1 and .tag == $tag and .activation_owner_run_id == $activation_owner_run_id and .activation_marker_sha256 == $activation_marker_sha256 and .convergence_run_id == $convergence_run_id and .lease_sha == $lease_sha and .owner_asset_name == $owner_asset_name' \ - "${verified_record}" >/dev/null - echo "[OK] Bound convergence run ${GITHUB_RUN_ID} to lease ${LEASE_SHA} for ${TAG}." - promote_floating_tags: name: Converge Docker aliases needs: acquire_customer_promotion_lease diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 86ec066d1..dd7fed319 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -28,12 +28,12 @@ on: default: '' type: string convergence_owner_asset_name: - description: 'Unique immutable owner record for the active convergence lease.' + description: 'Owner record stored in the exact convergence lease commit.' required: false default: '' type: string convergence_owner_asset_sha256: - description: 'SHA-256 digest of the immutable convergence owner record.' + description: 'SHA-256 digest of the commit-bound convergence owner record.' required: false default: '' type: string @@ -238,20 +238,21 @@ jobs: if [[ ! "${TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || \ [[ ! "${CONVERGENCE_RUN_ID}" =~ ^[0-9]+$ ]] || \ [[ ! "${LEASE_SHA}" =~ ^[0-9a-f]{40}$ ]] || \ - [[ "${OWNER_ASSET_NAME}" != "release-convergence-owner-${CONVERGENCE_RUN_ID}-"*"-${LEASE_SHA}.json" ]] || \ + [[ ! "${OWNER_ASSET_NAME}" =~ ^release-convergence-owner-${CONVERGENCE_RUN_ID}-[1-9][0-9]*\.json$ ]] || \ [[ ! "${OWNER_ASSET_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then echo "::error::Stable demo mutation requires an exact stable tag, convergence owner, and lease." exit 1 fi release_state="$( - gh release view "${TAG}" --repo "${GITHUB_REPOSITORY}" --json isDraft,isPrerelease,publishedAt,tagName \ - --jq '[.tagName, (.isDraft | tostring), (.isPrerelease | tostring), (.publishedAt // "")] | @tsv' + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${TAG}" \ + --jq '[.tag_name, (.draft | tostring), (.prerelease | tostring), (.immutable // false | tostring), (.published_at // "")] | @tsv' )" if [ "$(awk -F '\t' '{print $1}' <<<"${release_state}")" != "${TAG}" ] || \ [ "$(awk -F '\t' '{print $2}' <<<"${release_state}")" != "false" ] || \ [ "$(awk -F '\t' '{print $3}' <<<"${release_state}")" != "false" ] || \ - [ -z "$(awk -F '\t' '{print $4}' <<<"${release_state}")" ]; then - echo "::error::Stable demo mutation refuses inactive or prerelease tag ${TAG}." + [ "$(awk -F '\t' '{print $4}' <<<"${release_state}")" != "true" ] || \ + [ -z "$(awk -F '\t' '{print $5}' <<<"${release_state}")" ]; then + echo "::error::Stable demo mutation refuses mutable, inactive, or prerelease tag ${TAG}." exit 1 fi marker="$(mktemp)" @@ -276,16 +277,15 @@ jobs: fi curl -fsSL --retry 4 --retry-delay 2 --retry-all-errors \ -o "${owner_record}" \ - "https://github.com/${{ github.repository }}/releases/download/${TAG}/${OWNER_ASSET_NAME}" + "https://raw.githubusercontent.com/${{ github.repository }}/${LEASE_SHA}/${OWNER_ASSET_NAME}" printf '%s %s\n' "${OWNER_ASSET_SHA256}" "${owner_record}" | sha256sum --check -- jq -e \ --arg tag "${TAG}" \ --arg activation_owner_run_id "${activation_owner_run_id}" \ --arg activation_marker_sha256 "${activation_marker_sha256}" \ --arg convergence_run_id "${CONVERGENCE_RUN_ID}" \ - --arg lease_sha "${LEASE_SHA}" \ --arg owner_asset_name "${OWNER_ASSET_NAME}" \ - '.schema_version == 1 and .tag == $tag and .activation_owner_run_id == $activation_owner_run_id and .activation_marker_sha256 == $activation_marker_sha256 and .convergence_run_id == $convergence_run_id and .lease_sha == $lease_sha and .owner_asset_name == $owner_asset_name' \ + '.schema_version == 2 and .tag == $tag and .activation_owner_run_id == $activation_owner_run_id and .activation_marker_sha256 == $activation_marker_sha256 and .convergence_run_id == $convergence_run_id and .owner_asset_name == $owner_asset_name' \ "${owner_record}" >/dev/null rm -f "${marker}" "${owner_record}" echo "[OK] Stable demo mutation is bound to committed ${TAG} convergence run ${CONVERGENCE_RUN_ID}." diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 97177471d..99de4b03b 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -1729,11 +1729,14 @@ artifact-selection behaviour. run is completed, a fresh manual convergence dispatch from fixed workflow code may adopt the same immutable tag, source run, target commit, release ID, R2 prefix, and activation-marker digest. Adoption happens only after lease - acquisition and publishes a unique immutable owner record whose asset name - includes the new run ID, run attempt, and lease SHA. Downstream demo and paid - broker mutation must verify the exact passed owner filename and SHA-256. A - clobbered constant owner record is forbidden because cached prior bytes could - authorize stale ownership. + acquisition and writes a unique owner record into the exact lease commit. + The record name includes the new run ID and run attempt; downstream demo and + paid broker mutation must read it from the passed lease commit and verify the + exact filename and SHA-256. A unique evidence tag retains that exact commit + after the active lease ref is released. The release remains sealed after + publication, while every successor gets immutable, run-scoped Git evidence. + Reading a floating ref or a clobbered constant record is forbidden because + cached prior bytes could authorize stale ownership. A support-only private Pro prerelease image is a narrower exception for customer verification of an already-fixed defect. It may dispatch the private `Build Pro Release` workflow with `publish_docker_image=true`, diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index 1abfb93ad..c405db54d 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -3137,7 +3137,8 @@ func TestReleasePipelinePromotesOneImmutableCandidate(t *testing.T) { for _, needle := range []string{ `"repos/${GITHUB_REPOSITORY}/git/refs"`, `Bootstrapped absent customer-promotion lease ref`, - `git push origin "${lock_commit}:${LOCK_REF}"`, + `git push --atomic origin`, + `"${lock_commit}:${owner_ref}"`, } { if !strings.Contains(leaseScript, needle) { t.Fatalf("customer-promotion lease missing absent-ref bootstrap contract: %s", needle) diff --git a/scripts/release_control/customer_promotion_lease.sh b/scripts/release_control/customer_promotion_lease.sh index 4d1be8174..9423c9c6d 100755 --- a/scripts/release_control/customer_promotion_lease.sh +++ b/scripts/release_control/customer_promotion_lease.sh @@ -25,18 +25,81 @@ acquire() { return 1 fi + : "${TARGET_COMMITISH:?TARGET_COMMITISH is required}" + : "${RELEASE_ID:?RELEASE_ID is required}" + : "${SOURCE_RELEASE_RUN_ID:?SOURCE_RELEASE_RUN_ID is required}" + : "${R2_PREFIX:?R2_PREFIX is required}" + : "${ACTIVATION_OWNER_RUN_ID:?ACTIVATION_OWNER_RUN_ID is required}" + : "${ACTIVATION_MARKER_SHA256:?ACTIVATION_MARKER_SHA256 is required}" + if [[ ! "${TARGET_COMMITISH}" =~ ^[0-9a-f]{40}$ ]] || \ + [[ ! "${RELEASE_ID}" =~ ^[0-9]+$ ]] || \ + [[ ! "${SOURCE_RELEASE_RUN_ID}" =~ ^[0-9]+$ ]] || \ + [[ ! "${ACTIVATION_OWNER_RUN_ID}" =~ ^[0-9]+$ ]] || \ + [[ ! "${ACTIVATION_MARKER_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::Customer-promotion owner identity is malformed." + return 1 + fi + export GIT_AUTHOR_NAME="${GITHUB_ACTOR}" export GIT_AUTHOR_EMAIL="${GITHUB_ACTOR_ID}+${GITHUB_ACTOR}@users.noreply.github.com" export GIT_COMMITTER_NAME="${GIT_AUTHOR_NAME}" export GIT_COMMITTER_EMAIL="${GIT_AUTHOR_EMAIL}" local lock_message="Release customer promotion lock run=${GITHUB_RUN_ID} attempt=${GITHUB_RUN_ATTEMPT} owner=${tag}" - local lock_commit - lock_commit="$(printf '%s\n' "${lock_message}" | git commit-tree "$(git rev-parse 'HEAD^{tree}')" -p HEAD)" + local owner_dir owner_record owner_asset_name owner_asset_sha256 owner_ref + local owner_blob lock_index lock_tree lock_commit + owner_dir="$(mktemp -d)" + lock_index="$(mktemp)" + owner_asset_name="release-convergence-owner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json" + owner_ref="refs/tags/${owner_asset_name%.json}" + owner_record="${owner_dir}/${owner_asset_name}" + jq -n \ + --arg tag "${tag}" \ + --arg target_commitish "${TARGET_COMMITISH}" \ + --arg release_id "${RELEASE_ID}" \ + --arg source_release_run_id "${SOURCE_RELEASE_RUN_ID}" \ + --arg r2_prefix "${R2_PREFIX}" \ + --arg activation_owner_run_id "${ACTIVATION_OWNER_RUN_ID}" \ + --arg activation_marker_sha256 "${ACTIVATION_MARKER_SHA256}" \ + --arg convergence_run_id "${GITHUB_RUN_ID}" \ + --arg owner_asset_name "${owner_asset_name}" \ + '{ + schema_version: 2, + tag: $tag, + target_commitish: $target_commitish, + release_id: $release_id, + source_release_run_id: $source_release_run_id, + r2_prefix: $r2_prefix, + activation_owner_run_id: $activation_owner_run_id, + activation_marker_sha256: $activation_marker_sha256, + convergence_run_id: $convergence_run_id, + owner_asset_name: $owner_asset_name + }' > "${owner_record}" + owner_asset_sha256="$(sha256sum "${owner_record}" | awk '{print $1}')" + + # Store the owner record inside the lease commit itself. The exact lock SHA + # therefore addresses both the active lease and immutable evidence for its + # owner without trying to append an asset to an already-published release. + owner_blob="$(git hash-object -w "${owner_record}")" + GIT_INDEX_FILE="${lock_index}" git read-tree HEAD + GIT_INDEX_FILE="${lock_index}" git update-index --add --cacheinfo \ + "100644,${owner_blob},${owner_asset_name}" + lock_tree="$(GIT_INDEX_FILE="${lock_index}" git write-tree)" + lock_commit="$(printf '%s\n' "${lock_message}" | git commit-tree "${lock_tree}" -p HEAD)" local deadline=$((SECONDS + timeout_seconds)) while (( SECONDS < deadline )); do - if git push origin "${lock_commit}:${LOCK_REF}" >/dev/null 2>&1; then - echo "lock_sha=${lock_commit}" >> "${GITHUB_OUTPUT}" + # The unique evidence tag retains the exact commit after the active lock ref + # is released. Atomic creation prevents either ref from existing alone. + if git push --atomic origin \ + "${lock_commit}:${LOCK_REF}" \ + "${lock_commit}:${owner_ref}" >/dev/null 2>&1; then + { + echo "lock_sha=${lock_commit}" + echo "owner_asset_name=${owner_asset_name}" + echo "owner_asset_sha256=${owner_asset_sha256}" + } >> "${GITHUB_OUTPUT}" + rm -rf -- "${owner_dir}" + rm -f -- "${lock_index}" echo "[OK] Acquired global customer-promotion lease ${lock_commit}." return 0 fi @@ -92,6 +155,8 @@ acquire() { sleep 30 done + rm -rf -- "${owner_dir}" + rm -f -- "${lock_index}" echo "::error::Timed out acquiring the global customer-promotion lease." return 1 } diff --git a/scripts/release_control/release_promotion_policy_test.py b/scripts/release_control/release_promotion_policy_test.py index 2ab603e76..32fa59d2c 100644 --- a/scripts/release_control/release_promotion_policy_test.py +++ b/scripts/release_control/release_promotion_policy_test.py @@ -3,12 +3,14 @@ from __future__ import annotations +import hashlib +import json import os -from pathlib import PurePosixPath +from pathlib import Path, PurePosixPath import re import subprocess +import tempfile import unittest -import json import yaml from yaml.constructor import ConstructorError @@ -664,7 +666,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): "inputs.verify_only != true", "release-activation.json", ".convergence_run_id == $convergence_run_id", - "Stable demo mutation refuses inactive or prerelease tag", + "Stable demo mutation refuses mutable, inactive, or prerelease tag", + "https://raw.githubusercontent.com/${{ github.repository }}/${LEASE_SHA}/${OWNER_ASSET_NAME}", + ".immutable // false", + ".schema_version == 2", ): self.assertIn(needle, demo_mutation) self.assertIn("tag: ${{ inputs.tag }}", demo_caller) @@ -686,6 +691,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): "pulse_owner_asset_name: $pulse_owner_asset_name", "pulse_owner_asset_sha256: $pulse_owner_asset_sha256", "return_run_details: true", + ".immutable == true", + "https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${PULSE_LEASE_SHA}/${PULSE_OWNER_ASSET_NAME}", + ".schema_version == 2", + "Paid-runtime mutation is bound to immutable", ): self.assertIn(needle, paid_dispatch) @@ -703,7 +712,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("release-customer-promotion-lock", release_workflow) self.assertIn("customer_promotion_lease.sh acquire", lease) self.assertIn("refs/heads/release-customer-promotion-lock", lease_script) - self.assertIn("git push origin \"${lock_commit}:${LOCK_REF}\"", lease_script) + self.assertIn("git push --atomic origin", lease_script) + self.assertIn('"${lock_commit}:${owner_ref}"', lease_script) self.assertIn('"repos/${GITHUB_REPOSITORY}/git/refs"', lease_script) self.assertIn("Bootstrapped absent customer-promotion lease ref", lease_script) self.assertIn("--force-with-lease=\"${LOCK_REF}:${observed_sha}\"", lease_script) @@ -715,6 +725,10 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("no global customer pointer will move backward", lease) self.assertIn("customer_promotion_lease.sh release", release_lease) self.assertIn("--force-with-lease=\"${LOCK_REF}:${lock_sha}\"", lease_script) + self.assertIn('schema_version: 2', lease_script) + self.assertIn('git hash-object -w "${owner_record}"', lease_script) + self.assertIn('GIT_INDEX_FILE="${lock_index}" git update-index', lease_script) + self.assertNotIn('gh release upload "${TAG}" "${owner_record}"', lease) helm_pages = workflow_job_block(convergence, "publish_helm_pages") convergence_verdict = workflow_job_block(convergence, "convergence_verdict") @@ -854,6 +868,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase): convergence = read(".github/workflows/release-convergence.yml") await_commit = workflow_job_block(convergence, "await_activation_commit") lease = workflow_job_block(convergence, "acquire_customer_promotion_lease") + lease_script = read("scripts/release_control/customer_promotion_lease.sh") original_owner = "100" successor_owner = "200" @@ -870,18 +885,117 @@ class ReleasePromotionPolicyTest(unittest.TestCase): ) self.assertTrue(successor_may_adopt) self.assertIn("Adopting committed ${TAG} from completed convergence owner", await_commit) - self.assertIn("Bind this lease as the active convergence successor", lease) - self.assertIn("activation_owner_run_id", lease) - self.assertIn("activation_marker_sha256", lease) - self.assertIn("release-convergence-owner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${LEASE_SHA}.json", lease) - self.assertNotIn("#release-convergence-owner.json", lease) - self.assertIn("owner_asset_sha256", lease) - self.assertIn("sha256sum --check --", lease) + self.assertIn("ACTIVATION_OWNER_RUN_ID", lease) + self.assertIn("ACTIVATION_MARKER_SHA256", lease) + self.assertIn( + "release-convergence-owner-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}.json", + lease_script, + ) + self.assertIn("owner_asset_sha256", lease_script) + self.assertIn("git commit-tree", lease_script) + self.assertNotIn("gh release upload", lease) - stale_name = f"release-convergence-owner-{original_owner}-5-{'a' * 40}.json" - successor_name = f"release-convergence-owner-{successor_owner}-1-{'b' * 40}.json" + stale_name = f"release-convergence-owner-{original_owner}-5.json" + successor_name = f"release-convergence-owner-{successor_owner}-1.json" self.assertNotEqual(stale_name, successor_name) + def test_customer_promotion_lease_commit_contains_exact_owner_evidence(self) -> None: + script = REPO_ROOT / "scripts" / "release_control" / "customer_promotion_lease.sh" + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + remote = root / "remote.git" + checkout = root / "checkout" + output = root / "github-output" + subprocess.run(["git", "init", "--bare", str(remote)], check=True, capture_output=True) + subprocess.run(["git", "init", str(checkout)], check=True, capture_output=True) + for key, value in ( + ("user.name", "Pulse Test"), + ("user.email", "pulse-test@example.invalid"), + ): + subprocess.run( + ["git", "config", key, value], cwd=checkout, check=True + ) + (checkout / "README").write_text("base\n", encoding="utf-8") + subprocess.run(["git", "add", "README"], cwd=checkout, check=True) + subprocess.run(["git", "commit", "-m", "base"], cwd=checkout, check=True, capture_output=True) + subprocess.run(["git", "remote", "add", "origin", str(remote)], cwd=checkout, check=True) + subprocess.run( + ["git", "push", "origin", "HEAD:refs/heads/release-customer-promotion-lock"], + cwd=checkout, + check=True, + capture_output=True, + ) + + env = os.environ.copy() + env.update( + { + "GH_TOKEN": "test-token", + "GITHUB_REPOSITORY": "rcourtman/Pulse", + "GITHUB_RUN_ID": "200", + "GITHUB_RUN_ATTEMPT": "3", + "GITHUB_ACTOR": "pulse-test", + "GITHUB_ACTOR_ID": "1234", + "GITHUB_OUTPUT": str(output), + "TARGET_COMMITISH": "a" * 40, + "RELEASE_ID": "321", + "SOURCE_RELEASE_RUN_ID": "100", + "R2_PREFIX": "v6.5.0-pro-test", + "ACTIVATION_OWNER_RUN_ID": "150", + "ACTIVATION_MARKER_SHA256": "b" * 64, + } + ) + result = subprocess.run( + [str(script), "acquire", "v6.5.0"], + cwd=checkout, + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stderr) + outputs = dict( + line.split("=", 1) + for line in output.read_text(encoding="utf-8").splitlines() + ) + self.assertRegex(outputs["lock_sha"], r"^[0-9a-f]{40}$") + self.assertEqual( + outputs["owner_asset_name"], + "release-convergence-owner-200-3.json", + ) + record_text = subprocess.run( + [ + "git", + "show", + f"{outputs['lock_sha']}:{outputs['owner_asset_name']}", + ], + cwd=checkout, + text=True, + capture_output=True, + check=True, + ).stdout + record = json.loads(record_text) + self.assertEqual(record["schema_version"], 2) + self.assertEqual(record["convergence_run_id"], "200") + self.assertEqual(record["activation_owner_run_id"], "150") + self.assertNotIn("lease_sha", record) + self.assertEqual( + outputs["owner_asset_sha256"], + hashlib.sha256(record_text.encode()).hexdigest(), + ) + retained = subprocess.run( + [ + "git", + "ls-remote", + "origin", + "refs/tags/release-convergence-owner-200-3", + ], + cwd=checkout, + text=True, + capture_output=True, + check=True, + ).stdout.split()[0] + self.assertEqual(retained, outputs["lock_sha"]) + def test_private_dispatches_wait_for_the_exact_created_run(self) -> None: release_workflow = read(".github/workflows/create-release.yml") private_promotion_workflow = read( @@ -1498,7 +1612,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertNotIn("--enforce-prerelease-observation-window", dry_run_workflow) self.assertNotIn("--enforce-prerelease-observation-window", dry_run_helper) self.assertIn("render_release_body.py", content) - self.assertIn('--promotion-channel "${{ needs.prepare.outputs.release_stage }}"', content) + self.assertIn('WORKFLOW_OUTPUT_3: ${{ needs.prepare.outputs.release_stage }}', content) + self.assertIn('--promotion-channel "${WORKFLOW_OUTPUT_3}"', content) self.assertIn( "needs.prepare.outputs.release_stage != 'alpha' && needs.prepare.outputs.release_stage != 'beta'", content, @@ -1565,8 +1680,13 @@ class ReleasePromotionPolicyTest(unittest.TestCase): self.assertIn("statuses: write", validation_workflow) self.assertIn("curl --fail-with-body --silent --show-error -X POST", validation_workflow) self.assertIn('"context": "Release Asset Validation"', validation_workflow) - self.assertIn('--arg tag "${{ steps.context.outputs.tag }}"', validation_workflow) - self.assertIn('--arg target_commitish "${{ steps.context.outputs.target_commitish }}"', validation_workflow) + self.assertIn('WORKFLOW_OUTPUT_4: ${{ steps.context.outputs.tag }}', validation_workflow) + self.assertIn( + 'WORKFLOW_OUTPUT_5: ${{ steps.context.outputs.target_commitish }}', + validation_workflow, + ) + self.assertIn('--arg tag "${WORKFLOW_OUTPUT_4}"', validation_workflow) + self.assertIn('--arg target_commitish "${WORKFLOW_OUTPUT_5}"', validation_workflow) self.assertIn("{body: $body, tag_name: $tag, target_commitish: $target_commitish}", validation_workflow) self.assertIn("{draft: true, tag_name: $tag, target_commitish: $target_commitish}", validation_workflow) self.assertIn("Validation release body update detached release tag", validation_workflow) @@ -1606,8 +1726,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): 'ACTUAL_TARGET_COMMITISH=$(jq -r \'.target_commitish // empty\' "$RELEASE_JSON_FILE")', content, ) - self.assertIn('./scripts/backfill-release-assets.sh --tag "${{ needs.prepare.outputs.tag }}" --repo "${{ github.repository }}"', content) - self.assertIn('./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}"', content) + self.assertIn('WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }}', content) + self.assertIn('./scripts/backfill-release-assets.sh --tag "${WORKFLOW_OUTPUT_1}" --repo "${{ github.repository }}"', content) + self.assertIn('./scripts/validate-published-release.sh "${WORKFLOW_OUTPUT_1}" "${{ github.repository }}"', content) self.assertIn("PULSE_UPDATE_SIGNING_KEY: ${{ secrets.PULSE_UPDATE_SIGNING_KEY }}", content) self.assertIn("PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }}", content) self.assertIn( @@ -1616,8 +1737,9 @@ class ReleasePromotionPolicyTest(unittest.TestCase): - name: Validate published release packet env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} + WORKFLOW_OUTPUT_1: ${{ needs.prepare.outputs.tag }} run: | - ./scripts/validate-published-release.sh "${{ needs.prepare.outputs.tag }}" "${{ github.repository }}" + ./scripts/validate-published-release.sh "${WORKFLOW_OUTPUT_1}" "${{ github.repository }}" """ ), normalize_ws(content),