diff --git a/.github/workflows/lambda-layer-publish.yml b/.github/workflows/lambda-layer-publish.yml new file mode 100644 index 000000000..7443be957 --- /dev/null +++ b/.github/workflows/lambda-layer-publish.yml @@ -0,0 +1,367 @@ +name: Publish OTel Plugin Lambda Layer + +on: + release: + types: [published] + workflow_dispatch: + inputs: + regions: + description: "Comma-separated AWS Regions; defaults to all commercial Regions" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: lambda-layer-publish-${{ github.sha }} + cancel-in-progress: false + +env: + LAYER_NAME: aws-durable-execution-sdk-java-otel-plugin + SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && github.sha || github.event.release.tag_name }} + DEFAULT_LAYER_REGIONS: >- + af-south-1, + ap-east-1, + ap-east-2, + ap-northeast-1, + ap-northeast-2, + ap-northeast-3, + ap-south-1, + ap-south-2, + ap-southeast-1, + ap-southeast-2, + ap-southeast-3, + ap-southeast-4, + ap-southeast-5, + ap-southeast-6, + ap-southeast-7, + ca-central-1, + ca-west-1, + eu-central-1, + eu-central-2, + eu-north-1, + eu-south-1, + eu-south-2, + eu-west-1, + eu-west-2, + eu-west-3, + il-central-1, + me-central-1, + me-south-1, + mx-central-1, + sa-east-1, + us-east-1, + us-east-2, + us-west-1, + us-west-2 + +jobs: + build-layer: + if: >- + github.event_name == 'release' || + (github.event_name == 'workflow_dispatch' && + github.ref == format('refs/heads/{0}', github.event.repository.default_branch)) + runs-on: ubuntu-latest + outputs: + sdk_version: ${{ steps.version.outputs.sdk_version }} + source_sha: ${{ steps.source.outputs.sha }} + + steps: + - name: Checkout source + uses: actions/checkout@v7 + with: + fetch-depth: 0 + ref: ${{ env.SOURCE_REF }} + + - name: Record source revision + id: source + shell: bash + run: | + set -euo pipefail + + echo "epoch=$(git show -s --format=%ct HEAD)" >> "$GITHUB_OUTPUT" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: corretto + java-version: '17' + cache: maven + + - name: Determine SDK version + id: version + env: + RELEASE_TAG: ${{ github.event.release.tag_name }} + shell: bash + run: | + set -euo pipefail + + sdk_version="$(mvn -q help:evaluate -Dexpression=project.version -DforceStdout)" + if [ "$GITHUB_EVENT_NAME" = "release" ]; then + if [[ ! "$RELEASE_TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z][0-9A-Za-z.-]*)?)$ ]]; then + echo "Release tag must be a semantic version prefixed with v (for example, v1.2.0)." >&2 + exit 1 + fi + if [ "$sdk_version" != "${BASH_REMATCH[1]}" ]; then + echo "Release tag $RELEASE_TAG points to Maven version $sdk_version." >&2 + exit 1 + fi + fi + + echo "sdk_version=$sdk_version" >> "$GITHUB_OUTPUT" + + - name: Verify release source + if: github.event_name == 'release' + env: + BASE_BRANCH: ${{ github.event.repository.default_branch }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + shell: bash + run: | + set -euo pipefail + + git fetch origin "$BASE_BRANCH" + if ! git merge-base --is-ancestor "${RELEASE_TAG}^{commit}" "origin/${BASE_BRANCH}"; then + echo "Release tag $RELEASE_TAG does not point to a commit on $BASE_BRANCH." >&2 + exit 1 + fi + + - name: Build layer zip + id: build-layer + env: + LAYER_ZIP: dist/${{ env.LAYER_NAME }}.zip + SDK_VERSION: ${{ steps.version.outputs.sdk_version }} + SOURCE_DATE_EPOCH: ${{ steps.source.outputs.epoch }} + SOURCE_SHA: ${{ steps.source.outputs.sha }} + TZ: UTC + shell: bash + run: | + set -euo pipefail + + build_layer() { + local output_zip=$1 + + mvn -B -q -pl otel-plugin -am clean package \ + -DskipTests \ + -Dproject.build.outputTimestamp="$SOURCE_DATE_EPOCH" \ + --no-transfer-progress + rm -rf layer + rm -f "$output_zip" + mkdir -p layer/java/lib "$(dirname "$output_zip")" + cp "otel-plugin/target/aws-durable-execution-sdk-java-plugin-otel-${SDK_VERSION}.jar" \ + layer/java/lib/ + cp LICENSE NOTICE layer/ + find layer -exec touch -d "@${SOURCE_DATE_EPOCH}" {} + + ( + cd layer + find . -type f -print | LC_ALL=C sort | zip -X -q "$GITHUB_WORKSPACE/$output_zip" -@ + ) + } + + rebuild_zip="${LAYER_ZIP%.zip}-rebuild.zip" + build_layer "$LAYER_ZIP" + build_layer "$rebuild_zip" + if ! cmp --silent "$LAYER_ZIP" "$rebuild_zip"; then + echo "Layer builds for source $SOURCE_SHA are not reproducible." >&2 + exit 1 + fi + rm "$rebuild_zip" + echo "layer_zip=$LAYER_ZIP" >> "$GITHUB_OUTPUT" + + - name: Verify layer contents + env: + LAYER_ZIP: ${{ steps.build-layer.outputs.layer_zip }} + SDK_VERSION: ${{ steps.version.outputs.sdk_version }} + shell: bash + run: | + set -euo pipefail + + unzip -Z1 "$LAYER_ZIP" > layer-contents.txt + grep -Fx "java/lib/aws-durable-execution-sdk-java-plugin-otel-${SDK_VERSION}.jar" layer-contents.txt + grep -Fx "LICENSE" layer-contents.txt + grep -Fx "NOTICE" layer-contents.txt + if [ "$(grep -Ec '^java/lib/[^/]+\.jar$' layer-contents.txt)" -ne 1 ]; then + echo "The layer must contain only the OTel plugin JAR." >&2 + exit 1 + fi + + - name: Upload layer artifact + uses: actions/upload-artifact@v7 + with: + name: otel-plugin-layer + path: ${{ steps.build-layer.outputs.layer_zip }} + if-no-files-found: error + retention-days: 30 + + publish-layer: + needs: build-layer + runs-on: ubuntu-latest + environment: + name: lambda-layer-publish + permissions: + contents: read + id-token: write + env: + LAYER_REGIONS: ${{ inputs.regions || vars.LAYER_PUBLISH_REGIONS }} + + steps: + - name: Download layer artifact + uses: actions/download-artifact@v8 + with: + name: otel-plugin-layer + path: dist/ + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e6de054238d6b7531b4efff3b6587d9aade6a06c # v6.2.3 + with: + role-to-assume: ${{ secrets.LAYER_PUBLISH_ROLE_ARN }} + role-session-name: otelLayerPublish + aws-region: us-east-1 + + - name: Publish layer versions + env: + LAYER_ZIP: dist/${{ env.LAYER_NAME }}.zip + SDK_VERSION: ${{ needs.build-layer.outputs.sdk_version }} + SOURCE_SHA: ${{ needs.build-layer.outputs.source_sha }} + shell: bash + run: | + set -euo pipefail + + PUBLISHED=false + FAILED_REGIONS=() + LOCAL_CODE_SHA256=$(openssl dgst -sha256 -binary "$LAYER_ZIP" | openssl base64 -A) + REGION_LIST=${LAYER_REGIONS:-$DEFAULT_LAYER_REGIONS} + IFS=',' read -ra REGIONS <<< "$REGION_LIST" + for REGION in "${REGIONS[@]}"; do + REGION=$(echo "$REGION" | xargs) + if [ -z "$REGION" ]; then + continue + fi + + LAYER_DESCRIPTION="AWS Durable Execution SDK for Java ${SDK_VERSION} OTel plugin source:${SOURCE_SHA}" + if ! EXISTING_RESULT=$(aws lambda list-layer-versions \ + --layer-name "$LAYER_NAME" \ + --region "$REGION" \ + --query "LayerVersions[?Description=='${LAYER_DESCRIPTION}'].[LayerVersionArn,Version]" \ + --output text 2>&1); then + if [[ "$EXISTING_RESULT" == *"ResourceNotFoundException"* ]]; then + EXISTING_RESULT="" + else + echo "$EXISTING_RESULT" >&2 + echo "::warning::Failed to list existing layer versions in ${REGION}" + FAILED_REGIONS+=("$REGION") + continue + fi + fi + + if [ -n "$EXISTING_RESULT" ]; then + read -r LAYER_VERSION_ARN VERSION_NUMBER <<< "$EXISTING_RESULT" + if ! EXISTING_CODE_SHA256=$(aws lambda get-layer-version \ + --layer-name "$LAYER_NAME" \ + --version-number "$VERSION_NUMBER" \ + --region "$REGION" \ + --query 'Content.CodeSha256' \ + --output text 2>&1); then + echo "$EXISTING_CODE_SHA256" >&2 + echo "::warning::Failed to read ${LAYER_VERSION_ARN}" + FAILED_REGIONS+=("$REGION") + continue + fi + if [ "$EXISTING_CODE_SHA256" != "$LOCAL_CODE_SHA256" ]; then + echo "::error::Artifact hash mismatch for ${LAYER_VERSION_ARN}" + FAILED_REGIONS+=("$REGION") + continue + fi + echo "Reusing ${LAYER_VERSION_ARN}" + else + if ! PUBLISH_RESULT=$(aws lambda publish-layer-version \ + --layer-name "$LAYER_NAME" \ + --description "$LAYER_DESCRIPTION" \ + --zip-file "fileb://${LAYER_ZIP}" \ + --compatible-runtimes java17 java21 java25 \ + --compatible-architectures x86_64 arm64 \ + --license-info Apache-2.0 \ + --region "$REGION" \ + --query '[LayerVersionArn,Version,Content.CodeSha256]' \ + --output text); then + echo "::warning::Failed to publish the layer in ${REGION}" + FAILED_REGIONS+=("$REGION") + continue + fi + read -r LAYER_VERSION_ARN VERSION_NUMBER PUBLISHED_CODE_SHA256 <<< "$PUBLISH_RESULT" + if [ "$PUBLISHED_CODE_SHA256" != "$LOCAL_CODE_SHA256" ]; then + echo "::error::Published artifact hash mismatch for ${LAYER_VERSION_ARN}" + FAILED_REGIONS+=("$REGION") + continue + fi + fi + + if ! PERMISSION_RESULT=$(aws lambda add-layer-version-permission \ + --layer-name "$LAYER_NAME" \ + --version-number "$VERSION_NUMBER" \ + --statement-id public-layer-access \ + --action lambda:GetLayerVersion \ + --principal "*" \ + --region "$REGION" \ + 2>&1); then + if [[ "$PERMISSION_RESULT" != *"ResourceConflictException"* ]]; then + echo "$PERMISSION_RESULT" >&2 + echo "::warning::Failed to grant public access to ${LAYER_VERSION_ARN}" + FAILED_REGIONS+=("$REGION") + continue + fi + fi + + if ! POLICY_RESULT=$(aws lambda get-layer-version-policy \ + --layer-name "$LAYER_NAME" \ + --version-number "$VERSION_NUMBER" \ + --region "$REGION" \ + --query Policy \ + --output text 2>&1); then + echo "$POLICY_RESULT" >&2 + echo "::warning::Failed to read the access policy for ${LAYER_VERSION_ARN}" + FAILED_REGIONS+=("$REGION") + continue + fi + if ! jq -e ' + .Statement + | if type == "array" then . else [.] end + | any(.[]; + .Sid == "public-layer-access" + and .Effect == "Allow" + and ( + .Action == "lambda:GetLayerVersion" + or ( + (.Action | type) == "array" + and (.Action | index("lambda:GetLayerVersion")) != null + ) + ) + and ( + .Principal == "*" + or ( + (.Principal | type) == "object" + and .Principal.AWS == "*" + ) + ) + ) + ' <<< "$POLICY_RESULT" > /dev/null; then + echo "::error::Expected public access policy is missing for ${LAYER_VERSION_ARN}" + FAILED_REGIONS+=("$REGION") + continue + fi + + echo "Available ${LAYER_VERSION_ARN}" + echo "- \`${LAYER_VERSION_ARN}\`" >> "$GITHUB_STEP_SUMMARY" + PUBLISHED=true + done + + if [ "${#FAILED_REGIONS[@]}" -gt 0 ]; then + echo "::error::Layer publishing failed in: ${FAILED_REGIONS[*]}" + exit 1 + fi + + if [ "$PUBLISHED" != true ]; then + echo "No AWS regions were configured for layer publishing." + exit 1 + fi