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 11a0990d..ad3661b4 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 8c205dd693c0b84767b0ee10e854c325f4342efa Mon Sep 17 00:00:00 2001 From: nvasiu Date: Fri, 11 Sep 2026 18:18:15 +0000 Subject: [PATCH 2/2] ci: add preflight and verification to testing image release --- .github/scripts/is_newest_testing_version.py | 48 ++++ .github/scripts/parse_testing_version.py | 38 ++++ .../tests/test_is_newest_testing_version.py | 42 ++++ .../tests/test_parse_testing_version.py | 48 ++++ .github/workflows/ecr-release.yml | 207 ++++++++++++++++-- .github/workflows/test-parser.yml | 8 +- 6 files changed, 376 insertions(+), 15 deletions(-) create mode 100644 .github/scripts/is_newest_testing_version.py create mode 100644 .github/scripts/parse_testing_version.py create mode 100644 .github/scripts/tests/test_is_newest_testing_version.py create mode 100644 .github/scripts/tests/test_parse_testing_version.py diff --git a/.github/scripts/is_newest_testing_version.py b/.github/scripts/is_newest_testing_version.py new file mode 100644 index 00000000..ccdfb23a --- /dev/null +++ b/.github/scripts/is_newest_testing_version.py @@ -0,0 +1,48 @@ +#!/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 sys + +from packaging.version import InvalidVersion, Version + + +def is_newest(candidate: str, existing_tags: list[str]) -> bool: + """True if candidate is >= every v tag in existing_tags. + + Non-version tags (latest, malformed) are ignored. With no existing + version tags the candidate is newest by default. + """ + candidate_version = Version(candidate) + highest = candidate_version + for tag in existing_tags: + if not tag.startswith("v"): + continue + try: + version = Version(tag[1:]) + except InvalidVersion: + continue + if version > highest: + highest = version + return candidate_version >= highest + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Decide whether a release version is the newest published." + ) + parser.add_argument("--candidate", required=True) + parser.add_argument( + "tags", nargs="*", help="Existing image tags, e.g. v1.2.1 v2.0.0 latest" + ) + args = parser.parse_args(argv) + + print("true" if is_newest(args.candidate, args.tags) else "false") + 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 00000000..78b1c641 --- /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_is_newest_testing_version.py b/.github/scripts/tests/test_is_newest_testing_version.py new file mode 100644 index 00000000..f2095aac --- /dev/null +++ b/.github/scripts/tests/test_is_newest_testing_version.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: 2025-present Amazon.com, Inc. or its affiliates. +# +# SPDX-License-Identifier: Apache-2.0 +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(__file__))) + +from is_newest_testing_version import is_newest + + +def test_is_newest(): + test_cases = [ + # Newest so far + ("2.0.0", ["v1.2.1", "v1.1.3", "latest"], True), + # First ever release + ("1.0.0", [], True), + ("1.0.0", ["latest"], True), + # Equal to the highest counts as newest (idempotent re-publish) + ("2.0.0", ["v2.0.0", "latest"], True), + # Backport below the highest is NOT newest + ("1.1.3", ["v1.2.1", "v2.0.0", "latest"], False), + ("1.9.9", ["v2.0.0"], False), + # Malformed and non-version tags are ignored + ("2.0.1", ["v2.0.0", "notaversion", "latest", "v"], True), + # Pre-release ordering + ("2.0.0", ["v2.0.0rc1"], True), + ("2.0.0rc1", ["v2.0.0"], False), + ] + + for candidate, tags, expected in test_cases: + result = is_newest(candidate, tags) + # Assert is expected in test functions + assert result == expected, ( # noqa: S101 + f"Expected {expected} but got {result} for {candidate} against {tags}" + ) + + +if __name__ == "__main__": + test_is_newest() + sys.exit(0) 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 00000000..30125cdc --- /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 ad3661b4..4b512ae8 100644 --- a/.github/workflows/ecr-release.yml +++ b/.github/workflows/ecr-release.yml @@ -13,9 +13,109 @@ 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_publish: ${{ steps.plan.outputs.should_publish }} + 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: 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_publish=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** (version already published)" >> "$GITHUB_STEP_SUMMARY" + echo "should_publish=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_publish=true" >> "$GITHUB_OUTPUT" + fi + 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_publish == 'true' runs-on: ubuntu-latest permissions: contents: read @@ -24,7 +124,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 +156,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 +174,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" @@ -94,6 +187,15 @@ jobs: id-token: write needs: [build-and-upload-image-to-ecr] 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: @@ -120,11 +222,25 @@ jobs: --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 + - name: Create and push latest manifest when this is the newest version + env: + VERSION: ${{ needs.build-and-upload-image-to-ecr.outputs.version }} + ECR_REPOSITORY: ${{ env.ecr_repository_name }} run: | + python -m pip install --upgrade packaging + repo_name="${ECR_REPOSITORY##*/}" + existing_tags="$(aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --query 'imageDetails[].imageTags[]' --output text 2>/dev/null || true)" + newest="$(python .github/scripts/is_newest_testing_version.py --candidate "$VERSION" $existing_tags)" + if [[ "$newest" != "true" ]]; then + echo "v$VERSION is a backport, leaving latest unchanged." + exit 0 + fi 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 }}" + "${{ 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 }}" \ "${{ needs.build-and-upload-image-to-ecr.outputs.full_image_arm64 }}" \ --arch arm64 \ @@ -134,3 +250,66 @@ jobs: --arch amd64 \ --os linux docker manifest push "${{ needs.build-and-upload-image-to-ecr.outputs.ecr_registry_repository }}" + + verify-publish: + needs: [preflight, create-ecr-manifest-per-arch] + runs-on: ubuntu-latest + permissions: + 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: | + 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 + } + existing_tags="$(aws ecr-public describe-images \ + --region "${{ env.aws_region }}" \ + --repository-name "$repo_name" \ + --query 'imageDetails[].imageTags[]' --output text 2>/dev/null || true)" + newest="$(python .github/scripts/is_newest_testing_version.py --candidate "$VERSION" $existing_tags)" + publish_verified() { + local v; v="$(digest_for "v${VERSION}")" + [[ -z "$v" || "$v" == "None" ]] && return 1 + [[ "$newest" != "true" ]] && return 0 + [[ "$(digest_for latest)" == "$v" ]] + } + for attempt in $(seq 1 10); do + if publish_verified; then + echo "v$VERSION publish verified 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 + echo "::error::v$VERSION is published but latest did not advance to it (latest=$(digest_for latest), v$VERSION=$final_version)." + fi + exit 1 diff --git a/.github/workflows/test-parser.yml b/.github/workflows/test-parser.yml index 4fa94c4c..a47265a1 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/is_newest_testing_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/is_newest_testing_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_is_newest_testing_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