From a7ee0a9c4df91bd48cd3f8947710596cfbbb39b0 Mon Sep 17 00:00:00 2001 From: nvasiu Date: Thu, 10 Sep 2026 23:16:55 +0000 Subject: [PATCH 1/2] ci: gate testing image publish on testing release --- .github/workflows/ecr-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ecr-release.yml b/.github/workflows/ecr-release.yml index 11a0990d8..ad3661b4c 100644 --- a/.github/workflows/ecr-release.yml +++ b/.github/workflows/ecr-release.yml @@ -14,6 +14,8 @@ env: jobs: build-and-upload-image-to-ecr: + # Only publish when the release includes a new testing package version. + if: contains(github.event.release.tag_name, 'testing-v') runs-on: ubuntu-latest permissions: contents: read From 3f48987c4ff9c0455773c53a1af5c399f1d7d76f Mon Sep 17 00:00:00 2001 From: nvasiu Date: Fri, 11 Sep 2026 18:18:15 +0000 Subject: [PATCH 2/2] ci: gate and verify testing image release --- .github/scripts/current_latest_version.py | 56 +++ .github/scripts/parse_testing_version.py | 38 ++ .../tests/test_current_latest_version.py | 68 +++ .../tests/test_parse_testing_version.py | 48 +++ .github/workflows/ecr-release.yml | 390 ++++++++++++++++-- .github/workflows/test-parser.yml | 8 +- 6 files changed, 568 insertions(+), 40 deletions(-) create mode 100644 .github/scripts/current_latest_version.py create mode 100644 .github/scripts/parse_testing_version.py create mode 100644 .github/scripts/tests/test_current_latest_version.py create mode 100644 .github/scripts/tests/test_parse_testing_version.py diff --git a/.github/scripts/current_latest_version.py b/.github/scripts/current_latest_version.py new file mode 100644 index 000000000..8957bd8c1 --- /dev/null +++ b/.github/scripts/current_latest_version.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates. +# +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import argparse +import re + +from packaging.version import InvalidVersion, Version + +# Exactly vX.Y.Z with an optional PEP 440 suffix (rc1, -beta, .post1, +local), +# matching what this workflow publishes. A fourth numeric component (v1.2.3.4) +# is rejected: the suffix, if any, must not start with a dot followed by a digit. +TAG_RE = re.compile(r"^v[0-9]+\.[0-9]+\.[0-9]+(?![.0-9])") + + +def current_latest_version(tags_on_latest: list[str]) -> str: + """Return the highest vX.Y.Z tag sharing the latest digest, or empty. + + Empty means latest does not resolve to a version we recognize, whether it + carries no version tag or only off-grammar ones (a scheme change or a + hand-pushed tag). The caller treats empty as invalid and refuses to move + latest, since it cannot prove it is newer. If one digest carries several + version tags the max is returned, keeping the monotonic guard conservative. + """ + highest: Version | None = None + highest_tag = "" + for tag in tags_on_latest: + if not TAG_RE.match(tag): + continue + try: + version = Version(tag[1:]) + except InvalidVersion: + continue + if highest is None or version > highest: + highest = version + highest_tag = tag + return highest_tag + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Print the version tag latest currently resolves to." + ) + parser.add_argument( + "tags", nargs="*", help="Tags carried by the latest digest, e.g. v1.2.1 latest" + ) + args = parser.parse_args(argv) + + print(current_latest_version(args.tags)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/parse_testing_version.py b/.github/scripts/parse_testing_version.py new file mode 100644 index 000000000..78b1c6416 --- /dev/null +++ b/.github/scripts/parse_testing_version.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates. +# +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import os +import re + +# A release-tag component naming the testing package: testing-v with an +# optional pre-release suffix. Anchored and matched against a single comma-split +# component so prefixes like "mytesting-v1.2.1" are rejected. +_COMPONENT = re.compile(r"testing-v([0-9]+\.[0-9]+\.[0-9]+[0-9A-Za-z.-]*)\Z") + + +def parse_testing_version(release_tag: str) -> str: + """Return the testing version named by the release tag, or empty if none.""" + for part in release_tag.split(","): + match = _COMPONENT.fullmatch(part.strip()) + if match: + return match.group(1) + return "" + + +def main(): + release_tag = os.environ.get("RELEASE_TAG", "") + tag_version = parse_testing_version(release_tag) + + github_output = os.environ.get("GITHUB_OUTPUT") + if github_output: + with open(github_output, "a", encoding="utf-8") as f: + f.write(f"tag_version={tag_version}\n") + + print(tag_version) + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/tests/test_current_latest_version.py b/.github/scripts/tests/test_current_latest_version.py new file mode 100644 index 000000000..9a1dce979 --- /dev/null +++ b/.github/scripts/tests/test_current_latest_version.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates. +# +# SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from current_latest_version import current_latest_version + +CASES = [ + # No tags, or no recognized version on the digest -> empty + ([], ""), + (["latest"], ""), + (["latest", "some-branch"], ""), + (["nightly"], ""), + # Single version tag alongside latest + (["v1.2.3", "latest"], "v1.2.3"), + # Highest wins when several version tags share the digest + (["v1.2.3", "v1.3.0", "latest"], "v1.3.0"), + # Pre-release orders below its release + (["v2.0.0rc1", "v2.0.0"], "v2.0.0"), + (["v2.0.0rc1"], "v2.0.0rc1"), + # Original tag text preserved, not normalized + (["v2.0.0-beta"], "v2.0.0-beta"), + # Off-grammar tags are not recognized -> empty (caller fails closed) + (["v1"], ""), + (["v1.2"], ""), + (["v999"], ""), + (["v2025.09"], ""), + # A fourth numeric component is not our grammar -> empty + (["v1.2.3.4"], ""), + (["v999.0.0.1"], ""), + (["v1.2.3-x86_64", "v1.2.3-arm64"], ""), + # A good tag alongside a junk one still resolves cleanly + (["v1", "v1.2.3"], "v1.2.3"), + (["v1.2.3.4", "v1.2.3"], "v1.2.3"), +] + + +@pytest.mark.parametrize("tags,expected", CASES) +def test_current_latest_version(tags, expected): + assert current_latest_version(tags) == expected + + +def _run_standalone() -> int: + failures = 0 + for tags, expected in CASES: + got = current_latest_version(tags) + if got != expected: + failures += 1 + print( + f"FAIL: current_latest_version({tags!r}) = {got!r}, expected {expected!r}" + ) + if failures: + print(f"{failures} failing case(s)") + return 1 + print(f"all {len(CASES)} cases passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(_run_standalone()) diff --git a/.github/scripts/tests/test_parse_testing_version.py b/.github/scripts/tests/test_parse_testing_version.py new file mode 100644 index 000000000..30125cdc6 --- /dev/null +++ b/.github/scripts/tests/test_parse_testing_version.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from parse_testing_version import parse_testing_version + + +def test_parse_testing_version(): + test_cases = [ + # Testing-only tag enables the job + ("testing-v2.0.0", "2.0.0"), + ("testing-v1.2.1", "1.2.1"), + # Combined comma-separated tags resolve to the testing version + ("sdk-v2.0.0,testing-v2.0.0", "2.0.0"), + ("testing-v2.0.0,sdk-v2.1.0", "2.0.0"), + ("otel-v1.0.0,testing-v1.2.1,sdk-v2.0.0", "1.2.1"), + # SDK-only or OTel-only tags do not enable the job + ("sdk-v2.1.0", ""), + ("otel-v1.0.0", ""), + ("sdk-v2.0.0,otel-v1.0.0", ""), + # Malformed or unrelated prefixes must not match + ("not-testing-v1.2.1", ""), + ("sdk-v2.0.0,mytesting-v1.2.1", ""), + ("testing-v1.2", ""), + ("testing-version-1.2.1", ""), + # No release tag + ("", ""), + ("v2.0.0", ""), + ("random-text", ""), + # Pre-release suffix is kept + ("testing-v2.0.0rc1", "2.0.0rc1"), + ("testing-v2.0.0-beta,sdk-v1.0.0", "2.0.0-beta"), + ] + + for input_text, expected in test_cases: + result = parse_testing_version(input_text) + # Assert is expected in test functions + assert result == expected, ( # noqa: S101 + f"Expected '{expected}' but got '{result}' for input: {input_text}" + ) + + +if __name__ == "__main__": + test_parse_testing_version() + sys.exit(0) diff --git a/.github/workflows/ecr-release.yml b/.github/workflows/ecr-release.yml index ad3661b4c..ae6ec5342 100644 --- a/.github/workflows/ecr-release.yml +++ b/.github/workflows/ecr-release.yml @@ -13,9 +13,122 @@ env: ecr_repository_name: durable-functions/aws-durable-execution-emulator jobs: + preflight: + # Decide whether to publish before building anything. + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # ECR Public reads require an assumed role + outputs: + should_build: ${{ steps.plan.outputs.should_build }} + should_update_latest: ${{ steps.plan.outputs.should_update_latest }} + version: ${{ steps.plan.outputs.version }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Parse testing version from the release tag + id: tag + shell: bash + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + tag_version="$(python .github/scripts/parse_testing_version.py)" + if [[ -z "$tag_version" ]]; then + echo "Release tag does not name the testing package. Nothing to publish." + else + echo "tag names testing version: $tag_version" + fi + + - name: Require the ECR upload role secret + if: steps.tag.outputs.tag_version != '' + env: + ECR_UPLOAD_IAM_ROLE_ARN: ${{ secrets.ECR_UPLOAD_IAM_ROLE_ARN }} + run: | + if [[ -z "$ECR_UPLOAD_IAM_ROLE_ARN" ]]; then + echo "::error::Secret ECR_UPLOAD_IAM_ROLE_ARN is not set. Restore it before releasing." + exit 1 + fi + + - name: Verify the tag version matches the source + id: verify + if: steps.tag.outputs.tag_version != '' + shell: bash + env: + TAG_VERSION: ${{ steps.tag.outputs.tag_version }} + ABOUT_PATH: ${{ env.package_path }}/src/aws_durable_execution_sdk_python_testing/__about__.py + run: | + source_version="$(grep "^__version__" "$ABOUT_PATH" | cut -d'"' -f2)" + echo "source version: $source_version" + if [[ "$TAG_VERSION" != "$source_version" ]]; then + echo "::error::Release tag names testing-v$TAG_VERSION but __about__.py is $source_version. Aborting before any publish." + exit 1 + fi + echo "version=$source_version" >> "$GITHUB_OUTPUT" + + - name: Configure AWS Credentials + if: steps.tag.outputs.tag_version != '' + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ secrets.ECR_UPLOAD_IAM_ROLE_ARN }} + aws-region: ${{ env.aws_region }} + + - name: Check whether the image tag already exists + id: exists + if: steps.tag.outputs.tag_version != '' + shell: bash + env: + VERSION: ${{ steps.verify.outputs.version }} + ECR_REPOSITORY: ${{ env.ecr_repository_name }} + run: | + repo_name="${ECR_REPOSITORY##*/}" + if err="$(aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageTag="v${VERSION}" 2>&1 >/dev/null)"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + elif [[ "$err" == *ImageNotFoundException* || "$err" == *ReferencedImagesNotFoundException* ]]; then + echo "exists=false" >> "$GITHUB_OUTPUT" + else + echo "::error::describe-images failed for v$VERSION: $err" + exit 1 + fi + + - name: Emit release plan + id: plan + shell: bash + env: + TAG_VERSION: ${{ steps.tag.outputs.tag_version }} + VERSION: ${{ steps.verify.outputs.version }} + EXISTS: ${{ steps.exists.outputs.exists }} + run: | + echo "## Emulator image release plan" >> "$GITHUB_STEP_SUMMARY" + + if [[ -z "$TAG_VERSION" ]]; then + echo "- decision: **skip** (release does not name the testing package)" >> "$GITHUB_STEP_SUMMARY" + echo "should_build=false" >> "$GITHUB_OUTPUT" + echo "should_update_latest=false" >> "$GITHUB_OUTPUT" + echo "version=" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "- testing version: $VERSION" >> "$GITHUB_STEP_SUMMARY" + if [[ "$EXISTS" == "true" ]]; then + echo "- image tag v$VERSION: already present in public ECR" >> "$GITHUB_STEP_SUMMARY" + echo "- decision: **skip build**, reconcile latest and verify (version already published)" >> "$GITHUB_STEP_SUMMARY" + echo "should_build=false" >> "$GITHUB_OUTPUT" + else + echo "- image tag v$VERSION: not present in public ECR" >> "$GITHUB_STEP_SUMMARY" + echo "- decision: **publish**" >> "$GITHUB_STEP_SUMMARY" + echo "should_build=true" >> "$GITHUB_OUTPUT" + fi + echo "should_update_latest=true" >> "$GITHUB_OUTPUT" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + build-and-upload-image-to-ecr: - # Only publish when the release includes a new testing package version. - if: contains(github.event.release.tag_name, 'testing-v') + needs: preflight + if: needs.preflight.outputs.should_build == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -24,7 +137,7 @@ jobs: full_image_arm64: ${{ steps.build-publish.outputs.full_image_arm64 }} full_image_x86_64: ${{ steps.build-publish.outputs.full_image_x86_64 }} ecr_registry_repository: ${{ steps.build-publish.outputs.ecr_registry_repository }} - version: ${{ steps.version.outputs.VERSION }} + version: ${{ needs.preflight.outputs.version }} strategy: matrix: include: @@ -56,13 +169,6 @@ jobs: working-directory: ${{ env.package_path }} run: hatch build - - name: Get version from __about__.py - id: version - run: | - VERSION=$(grep "^__version__" "${{ env.package_path }}/src/aws_durable_execution_sdk_python_testing/__about__.py" | cut -d'"' -f2) - echo "VERSION=$VERSION" - echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" - - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 with: @@ -81,7 +187,7 @@ jobs: env: ECR_REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} ECR_REPOSITORY: ${{ env.ecr_repository_name }} - PER_ARCH_IMAGE_TAG: v${{ steps.version.outputs.VERSION }}-${{ matrix.arch }} + PER_ARCH_IMAGE_TAG: v${{ needs.preflight.outputs.version }}-${{ matrix.arch }} run: | docker build --platform "${{ matrix.platform }}" --provenance false "${{ env.package_path }}" -f "${{ env.package_path }}/Dockerfile" -t "$ECR_REGISTRY/$ECR_REPOSITORY:$PER_ARCH_IMAGE_TAG" docker push "$ECR_REGISTRY/$ECR_REPOSITORY:$PER_ARCH_IMAGE_TAG" @@ -91,9 +197,20 @@ jobs: create-ecr-manifest-per-arch: runs-on: ubuntu-latest permissions: + contents: read id-token: write - needs: [build-and-upload-image-to-ecr] + needs: [preflight, build-and-upload-image-to-ecr] + if: always() && needs.preflight.outputs.should_update_latest == 'true' && needs.build-and-upload-image-to-ecr.result != 'failure' && needs.build-and-upload-image-to-ecr.result != 'cancelled' steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 with: @@ -101,36 +218,231 @@ jobs: aws-region: ${{ env.aws_region }} - name: Login to Amazon ECR + id: login-ecr-public uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 with: registry-type: public - - name: Create and push explicit version manifest + - name: Create the version manifest + id: version-manifest + env: + VERSION: ${{ needs.preflight.outputs.version }} + BUILT_THIS_RUN: ${{ needs.build-and-upload-image-to-ecr.result == 'success' }} + ECR_REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} + ECR_REPOSITORY: ${{ env.ecr_repository_name }} run: | - docker manifest create "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}:v${{ needs.build-and-upload-image-to-ecr.outputs.version }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_x86_64 }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_arm64 }}" - docker manifest annotate "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}:v${{ needs.build-and-upload-image-to-ecr.outputs.version }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_arm64 }}" \ - --arch arm64 \ - --os linux - docker manifest annotate "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}:v${{ needs.build-and-upload-image-to-ecr.outputs.version }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_x86_64 }}" \ - --arch amd64 \ - --os linux - docker manifest push "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}:v${{ needs.build-and-upload-image-to-ecr.outputs.version }}" - - - name: Create and push latest manifest + repo_name="${ECR_REPOSITORY##*/}" + base="$ECR_REGISTRY/$ECR_REPOSITORY" + image_x86_64="$base:v$VERSION-x86_64" + image_arm64="$base:v$VERSION-arm64" + + # Recovery path: the build was skipped because v$VERSION already exists. + # Never rewrite a published release manifest from the mutable per-arch + # tags. Confirm it is present and leave it untouched. + if [[ "$BUILT_THIS_RUN" != "true" ]]; then + if aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageTag="v$VERSION" >/dev/null 2>&1; then + echo "v$VERSION already published, leaving its manifest untouched." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + echo "::error::v$VERSION was expected to exist but is missing, cannot reconcile. Rebuild manually." + exit 1 + fi + + docker manifest create "$base:v$VERSION" "$image_x86_64" "$image_arm64" + docker manifest annotate "$base:v$VERSION" "$image_arm64" --arch arm64 --os linux + docker manifest annotate "$base:v$VERSION" "$image_x86_64" --arch amd64 --os linux + docker manifest push "$base:v$VERSION" + + update-latest-manifest: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + needs: [preflight, create-ecr-manifest-per-arch] + if: always() && needs.create-ecr-manifest-per-arch.result == 'success' + # Serialize the latest update so two releases do not race it. + concurrency: + group: ecr-release-latest + cancel-in-progress: false + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ secrets.ECR_UPLOAD_IAM_ROLE_ARN }} + aws-region: ${{ env.aws_region }} + + - name: Login to Amazon ECR + id: login-ecr-public + uses: aws-actions/amazon-ecr-login@03f1aad4c6c7ffd436567f42f9384779290529bd # v2.1.7 + with: + registry-type: public + + - name: Advance latest to this version when it is newer + env: + VERSION: ${{ needs.preflight.outputs.version }} + ECR_REGISTRY: ${{ steps.login-ecr-public.outputs.registry }} + ECR_REPOSITORY: ${{ env.ecr_repository_name }} + run: | + python -m pip install --upgrade packaging + repo_name="${ECR_REPOSITORY##*/}" + base="$ECR_REGISTRY/$ECR_REPOSITORY" + + # Confirm this release's own manifest is present before touching latest. + # It is pushed by a prior job, so allow for registry propagation lag. + version_visible="" + for attempt in $(seq 1 10); do + if aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageTag="v$VERSION" >/dev/null 2>&1; then + version_visible="true" + break + fi + echo "attempt $attempt: v$VERSION not visible in public ECR yet, retrying." + sleep 15 + done + if [[ -z "$version_visible" ]]; then + echo "::error::v$VERSION manifest not found in public ECR after polling, cannot update latest." + exit 1 + fi + + # Resolve the version latest currently points at, if any. latest is + # only ever moved forward, so a backport or a re-run is a no-op. + # A lookup that fails (rather than returns nothing) must not fall + # through to an unconditional push: that would let a transient error + # downgrade latest. Distinguish absent from failed and fail closed. + if latest_digest="$(aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageTag="latest" \ + --query 'imageDetails[0].imageDigest' --output text 2>/dev/null)"; then + : + elif aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageTag="latest" 2>&1 | grep -q "ImageNotFoundException"; then + latest_digest="" + else + echo "::error::Could not read the current latest tag in public ECR, cannot safely update latest." + exit 1 + fi + + current="" + if [[ -n "$latest_digest" && "$latest_digest" != "None" ]]; then + if ! latest_tags="$(aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageDigest="$latest_digest" \ + --query 'imageDetails[0].imageTags[]' --output text)"; then + echo "::error::Could not read tags for the current latest image, cannot safely update latest." + exit 1 + fi + # current is the version latest resolves to, or empty. latest is + # expected to exist and carry a clean vX.Y.Z, so an empty result + # here means it resolves to something we cannot order against (a + # scheme change or a hand-pushed tag). We cannot prove we are newer, + # so refuse to overwrite latest and leave it for manual review. + current="$(python .github/scripts/current_latest_version.py $latest_tags)" + if [[ -z "$current" ]]; then + echo "::error::latest resolves to no recognized version ($latest_tags), refusing to update latest. Needs manual review." + exit 1 + fi + fi + + if [[ -n "$current" ]] && ! python -c "import sys; from packaging.version import Version; sys.exit(0 if Version('$VERSION') > Version('${current#v}') else 1)"; then + echo "latest points at $current, not advancing for older or equal v$VERSION." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + docker manifest create "$base" "$base:v$VERSION-x86_64" "$base:v$VERSION-arm64" + docker manifest annotate "$base" "$base:v$VERSION-arm64" --arch arm64 --os linux + docker manifest annotate "$base" "$base:v$VERSION-x86_64" --arch amd64 --os linux + docker manifest push "$base" + echo "latest now points at v$VERSION." >> "$GITHUB_STEP_SUMMARY" + + verify-publish: + needs: [preflight, update-latest-manifest] + if: always() && needs.preflight.outputs.should_update_latest == 'true' && needs.update-latest-manifest.result == 'success' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.13" + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@cbe3b392738ccf3f987d68400dafcf4b0624a56c # v6.2.4 + with: + role-to-assume: ${{ secrets.ECR_UPLOAD_IAM_ROLE_ARN }} + aws-region: ${{ env.aws_region }} + + - name: Verify the version and latest tags on public ECR + shell: bash + env: + VERSION: ${{ needs.preflight.outputs.version }} + ECR_REPOSITORY: ${{ env.ecr_repository_name }} run: | - docker manifest create "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_arm64 }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_x86_64 }}" - docker manifest annotate "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_arm64 }}" \ - --arch arm64 \ - --os linux - docker manifest annotate "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}" \ - "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_x86_64 }}" \ - --arch amd64 \ - --os linux - docker manifest push "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}" + repo_name="${ECR_REPOSITORY##*/}" + python -m pip install --upgrade packaging + digest_for() { + aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageTag="$1" \ + --query 'imageDetails[0].imageDigest' --output text 2>/dev/null || true + } + tags_on() { + aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --image-ids imageDigest="$1" \ + --query 'imageDetails[0].imageTags[]' --output text 2>/dev/null || true + } + # latest must resolve to a version at least this release. It is only + # ever advanced forward, so a newer latest is a valid pass too. + publish_verified() { + local v; v="$(digest_for "v${VERSION}")" + [[ -z "$v" || "$v" == "None" ]] && return 1 + local latest_digest; latest_digest="$(digest_for latest)" + [[ -z "$latest_digest" || "$latest_digest" == "None" ]] && return 1 + local latest_version + latest_version="$(python .github/scripts/current_latest_version.py $(tags_on "$latest_digest"))" + [[ -n "$latest_version" ]] || return 1 + python -c "import sys; from packaging.version import Version; sys.exit(0 if Version('${latest_version#v}') >= Version('${VERSION}') else 1)" + } + for attempt in $(seq 1 10); do + if publish_verified; then + echo "v$VERSION published and latest points at v$VERSION or newer in public ECR." + exit 0 + fi + echo "attempt $attempt: v$VERSION not fully visible in public ECR yet, retrying." + sleep 15 + done + final_version="$(digest_for "v${VERSION}")" + if [[ -z "$final_version" || "$final_version" == "None" ]]; then + echo "::error::v$VERSION did not become visible in public ECR after publishing." + else + latest_digest="$(digest_for latest)" + latest_version="$(python .github/scripts/current_latest_version.py $(tags_on "$latest_digest"))" + echo "::error::v$VERSION is published but latest resolves to ${latest_version:-no recognized version}, not this release or newer." + fi + exit 1 diff --git a/.github/workflows/test-parser.yml b/.github/workflows/test-parser.yml index 4fa94c4c8..2a9aa0bd7 100644 --- a/.github/workflows/test-parser.yml +++ b/.github/workflows/test-parser.yml @@ -5,7 +5,9 @@ on: paths: - '.github/scripts/build_lambda_layer.py' - '.github/scripts/check_otel_wheel_dependencies.py' + - '.github/scripts/current_latest_version.py' - '.github/scripts/parse_sdk_branch.py' + - '.github/scripts/parse_testing_version.py' - '.github/scripts/tests/**' - '.github/workflows/ai-pr-review.yml' - '.github/workflows/opentelemetry-conformance-tests.yml' @@ -15,7 +17,9 @@ on: paths: - '.github/scripts/build_lambda_layer.py' - '.github/scripts/check_otel_wheel_dependencies.py' + - '.github/scripts/current_latest_version.py' - '.github/scripts/parse_sdk_branch.py' + - '.github/scripts/parse_testing_version.py' - '.github/scripts/tests/**' - '.github/workflows/ai-pr-review.yml' - '.github/workflows/opentelemetry-conformance-tests.yml' @@ -39,5 +43,7 @@ jobs: .github/scripts/tests/test_ai_pr_review_workflow.py \ .github/scripts/tests/test_build_lambda_layer.py \ .github/scripts/tests/test_check_otel_wheel_dependencies.py \ + .github/scripts/tests/test_current_latest_version.py \ .github/scripts/tests/test_opentelemetry_conformance_workflow.py \ - .github/scripts/tests/test_parse_sdk_branch.py + .github/scripts/tests/test_parse_sdk_branch.py \ + .github/scripts/tests/test_parse_testing_version.py