diff --git a/.changeset/ninety-boxes-vanish.md b/.changeset/ninety-boxes-vanish.md new file mode 100644 index 000000000..a9b24828c --- /dev/null +++ b/.changeset/ninety-boxes-vanish.md @@ -0,0 +1,5 @@ +--- +"promote-image-ecr": minor +--- + +create promote-image-ecr diff --git a/actions/promote-image-ecr/README.md b/actions/promote-image-ecr/README.md new file mode 100644 index 000000000..aa6ba8c5a --- /dev/null +++ b/actions/promote-image-ecr/README.md @@ -0,0 +1,264 @@ +# Promote Image ECR Action + +Promote Docker images from one Amazon ECR registry to another using +[cosign](https://github.com/sigstore/cosign). The action assumes the provided +IAM roles to access both source and destination registries. Cosign is used for +image promotion and signature/attestation copying. + +## Features + +- Copy images between ECR registries in the same or different AWS regions +- **Copy signatures and attestations using cosign** +- Support for single image promotion +- Support for multiple images using matrix configuration +- Multi-arch manifest support (automatically included with cosign) +- Secure credential handling via AWS role assumption +- Automatic promotion summary on GitHub Actions summary page +- Artifact upload with detailed promotion results (Markdown and JSON) + +## Prerequisites + +- AWS IAM roles with appropriate ECR permissions: + - Source role: ECR read permissions (`ecr:GetAuthorizationToken`, + `ecr:BatchGetImage`, etc.) + - Destination role: ECR write permissions (`ecr:GetAuthorizationToken`, + `ecr:PutImage`, etc.) +- OIDC configuration for GitHub Actions to assume AWS roles + +## Inputs + +| Input | Description | Required | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -------- | +| `aws-region` | AWS region for both registries. Use `source-aws-region` and `destination-aws-region` for different regions instead. | No | +| `source-aws-region` | AWS region for source registry. Example: `eu-west-1`. Falls back to `aws-region` if not provided. | No\* | +| `destination-aws-region` | AWS region for destination registry. Example: `us-east-1`. Falls back to `aws-region` if not provided. | No\* | +| `source-role-arn` | IAM Role ARN to assume in SOURCE account (needs ECR read permissions) | Yes | +| `destination-role-arn` | IAM Role ARN to assume in DEST account (needs ECR write permissions) | Yes | + +\*At least one of `aws-region`, `source-aws-region`, or `destination-aws-region` +must be provided. + +| Input | Description | Required | +| ------------------------ | --------------------------------------------------------------------------------------------- | -------- | +| `source-registry` | Source registry host, e.g. `111111111111.dkr.ecr.eu-west-1.amazonaws.com` | Yes | +| `destination-registry` | Destination registry host, e.g. `222222222222.dkr.ecr.eu-west-1.amazonaws.com` | Yes | +| `source-repository` | Source repository name, e.g. `my-app` (not required if using `images` matrix) | No | +| `destination-repository` | Destination repository name, e.g. `my-app` (not required if using `images` matrix) | No | +| `source-tag` | Source tag (or digest if you use `@sha256:...`) (not required if using `images` matrix) | No | +| `destination-tag` | Destination tag (not required if using `images` matrix) | No | +| `images` | JSON array of images to promote. Takes precedence over individual inputs. See examples below. | No | + +## Outputs + +| Output | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `promoted-images` | JSON array of promoted images with their source and destination details, promotion duration, and status. Example: `[{"source_repository": "app1", "source_tag": "v1.0.0", "destination_repository": "app1", "destination_tag": "v1.0.0", "duration_seconds": "12", "status": "success"}]` | + +## Usage + +### Single Image Promotion + +```yaml +name: Promote Image +on: + workflow_dispatch: + +jobs: + promote: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Promote image + uses: ./.github/actions/promote-image + with: + source_aws_region: eu-west-1 + destination_aws_region: us-east-1 + source_role_arn: arn:aws:iam::111111111111:role/github-actions-ecr-read + destination_role_arn: arn:aws:iam::222222222222:role/github-actions-ecr-write + source_registry: 111111111111.dkr.ecr.eu-west-1.amazonaws.com + destination_registry: 222222222222.dkr.ecr.us-east-1.amazonaws.com + source_repository: my-app + destination_repository: my-app + source_tag: v1.0.0 + destination_tag: v1.0.0 +``` + +### Multiple Images Promotion (Matrix) + +```yaml +name: Promote Multiple Images +on: + workflow_dispatch: + +jobs: + promote: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + + - name: Promote multiple images + uses: ./.github/actions/promote-image + with: + source_aws_region: eu-west-1 + destination_aws_region: us-east-1 + source_role_arn: arn:aws:iam::111111111111:role/github-actions-ecr-read + destination_role_arn: arn:aws:iam::222222222222:role/github-actions-ecr-write + source_registry: 111111111111.dkr.ecr.eu-west-1.amazonaws.com + destination_registry: 222222222222.dkr.ecr.us-east-1.amazonaws.com + images: | + [ + { + "source_repository": "app1", + "destination_repository": "app1", + "source_tag": "v1.0.0", + "destination_tag": "v1.0.0" + }, + { + "source_repository": "app2", + "destination_repository": "app2", + "source_tag": "v2.0.0", + "destination_tag": "v2.0.0" + }, + { + "source_repository": "service-x", + "destination_repository": "service-x", + "source_tag": "sha-abc123", + "destination_tag": "production" + } + ] +``` + +### Using with GitHub Matrix Strategy + +You can also use GitHub's matrix strategy to run promotions in parallel: + +```yaml +name: Promote Images in Parallel +on: + workflow_dispatch: + +jobs: + promote: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + strategy: + matrix: + image: + - { repo: "app1", tag: "v1.0.0" } + - { repo: "app2", tag: "v2.0.0" } + - { repo: "service-x", tag: "sha-abc123" } + steps: + - uses: actions/checkout@v4 + + - name: Promote ${{ matrix.image.repo }} + uses: ./.github/actions/promote-image + with: + source_aws_region: eu-west-1 + destination_aws_region: us-east-1 + source_role_arn: arn:aws:iam::111111111111:role/github-actions-ecr-read + destination_role_arn: arn:aws:iam::222222222222:role/github-actions-ecr-write + source_registry: 111111111111.dkr.ecr.eu-west-1.amazonaws.com + destination_registry: 222222222222.dkr.ecr.us-east-1.amazonaws.com + source_repository: ${{ matrix.image.repo }} + destination_repository: ${{ matrix.image.repo }} + source_tag: ${{ matrix.image.tag }} + destination_tag: ${{ matrix.image.tag }} +``` + +## Copy Tool + +This action uses [cosign](https://github.com/sigstore/cosign) exclusively to +copy images. This provides: + +- **Signature and attestation copying**: Automatically includes all signatures + and attestations +- **Multi-architecture support**: Copies all architectures by default +- **Modern compatibility**: Works with modern signing workflows (Sigstore, + cosign) + +## How It Works + +1. Installs `cosign` in the runner environment +2. Assumes the source IAM role and retrieves ECR credentials +3. Assumes the destination IAM role and retrieves ECR credentials +4. Uses `cosign copy` to transfer the image(s) between registries, including all + architectures, signatures, and attestations +5. For matrix mode, iterates through all images sequentially +6. Generates a promotion summary with detailed results +7. Displays the summary on the GitHub Actions summary page +8. Uploads promotion results as an artifact (both Markdown and JSON formats) + +## Promotion Results + +After promotion completes, the action provides: + +### GitHub Actions Summary + +A formatted summary appears on the job summary page showing: + +- Timestamp of promotion +- List of promoted images with source and destination details +- Source and destination AWS regions +- Duration for each image +- Success/failure status + +### Artifact Upload + +An artifact named `promotion-results-` is uploaded containing: + +- `promotion-summary.md`: Human-readable Markdown summary +- `promotion-results.json`: Machine-readable JSON with all promotion details + +Artifacts are retained for 30 days and can be downloaded for auditing or +integration with other tools. + +**Example JSON structure:** + +```json +{ + "promotions": [ + { + "source_repository": "app1", + "source_tag": "v1.0.0", + "destination_repository": "app1", + "destination_tag": "v1.0.0", + "duration_seconds": "12", + "status": "success" + } + ] +} +``` + +## Notes + +- Registries can be in the same or different AWS regions +- The action uses basic authentication with ECR (username is always "AWS") +- Credentials are passed securely via environment variables +- When using the `images` input, it takes precedence over individual + repository/tag inputs +- Promotion results are always uploaded as artifacts, even if the action fails + (use `if: always()` in the upload step) +- The script is implemented with reusable functions for both JSON and Markdown + output, ensuring maintainability and clarity. + +## Troubleshooting + +### Image Not Found + +Verify the source repository name and tag are correct, and that the image exists +in the source registry. + +### Multi-Arch Issues + +If you're seeing issues with multi-architecture images, ensure your source +images are properly built as multi-arch manifests. Cosign will copy all +architectures by default. diff --git a/actions/promote-image-ecr/action.yaml b/actions/promote-image-ecr/action.yaml new file mode 100644 index 000000000..892b6a5d3 --- /dev/null +++ b/actions/promote-image-ecr/action.yaml @@ -0,0 +1,151 @@ +name: Promote image between ECR registries +description: | + Promote an image from one ECR registry to another, using cosign (default) or skopeo. + The action will assume the provided IAM roles to access the source and destination registries. + +inputs: + aws-region: + description: + "AWS region for BOTH registries. Use source_aws_region and + destination-aws-region instead." + required: false + source-aws-region: + description: + "AWS region for source registry. Example: eu-west-1. Falls back to + aws-region if not provided." + required: false + destination-aws-region: + description: + "AWS region for destination registry. Example: us-east-1. Falls back to + aws-region if not provided." + required: false + source-role-arn: + description: + "IAM Role ARN to assume in SOURCE account (needs ECR read permissions)" + required: true + destination-role-arn: + description: + IAM Role ARN to assume in DEST account (needs ECR write permissions) + required: true + source-registry: + description: + Source registry host, e.g. 111111111111.dkr.ecr.eu-west-1.amazonaws.com + required: true + destination-registry: + description: + Destination registry host, e.g. + 222222222222.dkr.ecr.eu-west-1.amazonaws.com + required: true + source-repository: + description: + Source repository name, e.g. my-app (not required if using images matrix) + required: false + destination-repository: + description: + Destination repository name, e.g. my-app (not required if using images + matrix) + required: false + source-tag: + description: + Source tag (or digest if you use @sha256:... in advanced mode) (not + required if using images matrix) + required: false + destination-tag: + description: Destination tag (not required if using images matrix) + required: false + images: + description: | + JSON array of images to promote. Each object should have: sourceRepository, destinationRepository, sourceTag, destinationTag. + Example: [{"sourceRepository":"app1","destinationRepository":"app1","sourceTag":"v1.0","destinationTag":"v1.0"}] + If provided, this takes precedence over individual sourceRepository/destinationRepository/sourceTag/destinationTag inputs. + required: false + default: "" + copy-signatures: + description: + Use cosign to copy images (includes signatures and attestations). Set to + 'false' to use skopeo instead. + required: false + default: "true" + +outputs: + promoted-images: + description: + JSON array of promoted images with their source and destination details, + promotion duration, and status. + value: ${{ steps.promoted-images.outputs.promoted-images }} + +runs: + using: "composite" + steps: + - name: Install cosign + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + with: + cosign-release: "v3.0.2" + + - name: Configure AWS credentials (SOURCE) + uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6 + with: + role-to-assume: ${{ inputs.source-role-arn }} + aws-region: ${{ inputs.source-aws-region || inputs.aws-region }} + + - name: Login to Amazon ECR (SOURCE) + id: src + uses: aws-actions/amazon-ecr-login@v2 + + - name: Configure AWS credentials (DESTINATION) + uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6 + with: + role-to-assume: ${{ inputs.destination-role-arn }} + aws-region: ${{ inputs.destination-aws-region || inputs.aws-region }} + + - name: Login to Amazon ECR (DESTINATION) + id: dst + uses: aws-actions/amazon-ecr-login@v2 + + - name: Copy image + shell: bash + env: + SOURCE_REGISTRY: ${{ inputs.source-registry }} + DESTINATION_REGISTRY: ${{ inputs.destination-registry }} + SOURCE_REPOSITORY: ${{ inputs.source-repository }} + DESTINATION_REPOSITORY: ${{ inputs.destination-repository }} + SOURCE_TAG: ${{ inputs.source-tag }} + DESTINATION_TAG: ${{ inputs.destination-tag }} + IMAGES_JSON: ${{ inputs.images }} + SKOPEO_ARGS: ${{ inputs.skopeo-additional-args }} + SOURCE_AWS_REGION: ${{ inputs.source-aws-region || inputs.aws-region }} + DESTINATION_AWS_REGION: + ${{ inputs.destination-aws-region || inputs.aws-region }} + SRC_PASS: ${{ steps.src.outputs.password }} + DST_PASS: ${{ steps.dst.outputs.password }} + COPY_SIGNATURES: ${{ inputs.copy-signatures }} + ACTION_PATH: ${{ github.action_path }} + run: | + "${ACTION_PATH}/scripts/promote-images.sh" + + - name: Generate Job Summary + if: always() + shell: bash + run: | + if [ -f /tmp/promotion-results/promotion-summary.md ]; then + cat /tmp/promotion-results/promotion-summary.md >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload Promotion Results + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: promotion-results-${{ github.run_id }} + path: /tmp/promotion-results/ + retention-days: 30 + + - name: Set output promoted-images + id: promoted-images + if: always() + shell: bash + run: | + if [ -f /tmp/promotion-results/promoted-images.json ]; then + echo "promoted-images=$(cat /tmp/promotion-results/promoted-images.json)" >> $GITHUB_OUTPUT + else + echo "promoted-images=[]" >> $GITHUB_OUTPUT + fi diff --git a/actions/promote-image-ecr/package.json b/actions/promote-image-ecr/package.json new file mode 100644 index 000000000..6e0b1a5d5 --- /dev/null +++ b/actions/promote-image-ecr/package.json @@ -0,0 +1,11 @@ +{ + "name": "promote-image-ecr", + "version": "0.0.0", + "description": "", + "private": true, + "scripts": {}, + "author": "@smartcontractkit", + "license": "MIT", + "dependencies": {}, + "repository": "https://github.com/smartcontractkit/.github" +} diff --git a/actions/promote-image-ecr/scripts/promote-images.sh b/actions/promote-image-ecr/scripts/promote-images.sh new file mode 100755 index 000000000..fc76653b7 --- /dev/null +++ b/actions/promote-image-ecr/scripts/promote-images.sh @@ -0,0 +1,166 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# Create results directory and file +mkdir -p /tmp/promotion-results +RESULTS_FILE="/tmp/promotion-results/promotion-summary.md" +RESULTS_JSON="/tmp/promotion-results/promotion-results.json" + +echo "# Image Promotion Results" > "$RESULTS_FILE" +echo "" >> "$RESULTS_FILE" +echo "**Date:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')" >> "$RESULTS_FILE" +echo "**Copy Tool:** cosign" >> "$RESULTS_FILE" +echo "" >> "$RESULTS_FILE" + +# Initialize JSON results +echo '{"promotions": []}' > "$RESULTS_JSON" + +# Function to copy image using cosign only +copy_image() { + local src="$1" + local dst="$2" + # cosign copy includes signatures and attestations by default + cosign copy "${src}" "${dst}" +} + +# Function to append promotion result to markdown +write_markdown_result() { + local repo="$1" + local src_tag="$2" + local dst_repo="$3" + local dst_tag="$4" + local src_region="$5" + local dst_region="$6" + local duration="$7" + local status="$8" + local emoji="" + # Use check mark for success and cross mark for failure + if [[ "$status" == "success" ]]; then + emoji="✅" + else + emoji="❌" + fi + echo "### $emoji ${repo}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${repo}:${src_tag}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${dst_repo}:${dst_tag}\`" >> "$RESULTS_FILE" + if [[ -n "$src_region" ]]; then + echo "- **Source Region:** \`${src_region}\`" >> "$RESULTS_FILE" + fi + if [[ -n "$dst_region" ]]; then + echo "- **Destination Region:** \`${dst_region}\`" >> "$RESULTS_FILE" + fi + if [[ "$status" == "success" ]]; then + echo "- **Duration:** ${duration}s" >> "$RESULTS_FILE" + fi + echo "- **Status:** ${status^}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" +} + +# Function to append promotion result to JSON +write_promotion_json() { + local src_repo="$1" + local src_tag="$2" + local dst_repo="$3" + local dst_tag="$4" + local duration="$5" + local status="$6" + # Append promotion result to JSON array + jq --arg src_repo "$src_repo" \ + --arg src_tag "$src_tag" \ + --arg dst_repo "$dst_repo" \ + --arg dst_tag "$dst_tag" \ + --arg duration "$duration" \ + --arg status "$status" \ + '.promotions += [{ + "source_repository": $src_repo, + "source_tag": $src_tag, + "destination_repository": $dst_repo, + "destination_tag": $dst_tag, + "duration_seconds": $duration, + "status": $status + }]' "$RESULTS_JSON" > "${RESULTS_JSON}.tmp" && mv "${RESULTS_JSON}.tmp" "$RESULTS_JSON" +} + + +# Check if images matrix is provided +if [[ -n "$IMAGES_JSON" ]]; then + # Process multiple images + echo "Processing multiple images from matrix..." + echo "" >> "$RESULTS_FILE" + echo "## Promoted Images" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + IMAGE_COUNT=0 + echo "$IMAGES_JSON" | jq -c '.[]' | while read -r image; do + SRC_REPO=$(echo "$image" | jq -r '.source_repository') + DST_REPO=$(echo "$image" | jq -r '.destination_repository') + SRC_TAG=$(echo "$image" | jq -r '.source_tag') + DST_TAG=$(echo "$image" | jq -r '.destination_tag') + + SRC="docker://${SOURCE_REGISTRY}/${SRC_REPO}:${SRC_TAG}" + DST="docker://${DESTINATION_REGISTRY}/${DST_REPO}:${DST_TAG}" + + echo "-> Copying ${SRC} to ${DST}" + + START_TIME=$(date +%s) + if copy_image "${SRC}" "${DST}"; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + echo "✓ Successfully copied ${SRC} to ${DST} (took ${DURATION}s)" + + # Append to markdown + write_markdown_result "$SRC_REPO" "$SRC_TAG" "$DST_REPO" "$DST_TAG" "$SOURCE_AWS_REGION" "$DESTINATION_AWS_REGION" "$DURATION" "success" + # Append to JSON + write_promotion_json "$SRC_REPO" "$SRC_TAG" "$DST_REPO" "$DST_TAG" "$DURATION" "success" + + IMAGE_COUNT=$((IMAGE_COUNT + 1)) + else + echo "✗ Failed to copy ${SRC} to ${DST}" + + # Append failure to markdown + write_markdown_result "$SRC_REPO" "$SRC_TAG" "$DST_REPO" "$DST_TAG" "$SOURCE_AWS_REGION" "$DESTINATION_AWS_REGION" "0" "failed" + # Append failure to JSON + write_promotion_json "$SRC_REPO" "$SRC_TAG" "$DST_REPO" "$DST_TAG" "0" "failed" + + exit 1 + fi + done + echo "----------------------------------------" + echo "All ${IMAGE_COUNT} images copied successfully!" + + # Add summary at the top + sed -i "3i\\** Total Images Promoted:** ${IMAGE_COUNT}" "$RESULTS_FILE" + sed -i "4i\\" "$RESULTS_FILE" +else + # Process single image + SRC="docker://${SOURCE_REGISTRY}/${SOURCE_REPOSITORY}:${SOURCE_TAG}" + DST="docker://${DESTINATION_REGISTRY}/${DESTINATION_REPOSITORY}:${DESTINATION_TAG}" + + echo "-> Copying ${SRC} to ${DST}" + + echo "## Promoted Image" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + START_TIME=$(date +%s) + if copy_image "${SRC}" "${DST}"; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + echo "✓ Successfully copied ${SRC} to ${DST} (took ${DURATION}s)" + + # Write to markdown + write_markdown_result "$SOURCE_REPOSITORY" "$SOURCE_TAG" "$DESTINATION_REPOSITORY" "$DESTINATION_TAG" "$SOURCE_AWS_REGION" "$DESTINATION_AWS_REGION" "$DURATION" "success" + # Write to JSON + write_promotion_json "$SOURCE_REPOSITORY" "$SOURCE_TAG" "$DESTINATION_REPOSITORY" "$DESTINATION_TAG" "$DURATION" "success" + else + echo "✗ Failed to copy ${SRC} to ${DST}" + # Write failure to markdown + write_markdown_result "$SOURCE_REPOSITORY" "$SOURCE_TAG" "$DESTINATION_REPOSITORY" "$DESTINATION_TAG" "$SOURCE_AWS_REGION" "$DESTINATION_AWS_REGION" "0" "failed" + # Write failure to JSON + write_promotion_json "$SOURCE_REPOSITORY" "$SOURCE_TAG" "$DESTINATION_REPOSITORY" "$DESTINATION_TAG" "0" "failed" + + exit 1 + fi +fi