From 27b0ceb520690ebaf6662fb64be6b4a4d04fd69f Mon Sep 17 00:00:00 2001 From: kdihalas Date: Wed, 18 Feb 2026 14:22:01 +0000 Subject: [PATCH 1/5] feat: add promote-image-ecr --- actions/promote-image-ecr/README.md | 240 +++++++++++++++++++++ actions/promote-image-ecr/action.yaml | 276 +++++++++++++++++++++++++ actions/promote-image-ecr/package.json | 11 + 3 files changed, 527 insertions(+) create mode 100644 actions/promote-image-ecr/README.md create mode 100644 actions/promote-image-ecr/action.yaml create mode 100644 actions/promote-image-ecr/package.json diff --git a/actions/promote-image-ecr/README.md b/actions/promote-image-ecr/README.md new file mode 100644 index 00000000..09141224 --- /dev/null +++ b/actions/promote-image-ecr/README.md @@ -0,0 +1,240 @@ +# Promote Image Action + +Promote Docker images from one Amazon ECR registry to another using [skopeo](https://github.com/containers/skopeo). The action assumes the provided IAM roles to access both source and destination registries. + +## Features + +- Copy images between ECR registries in the same or different AWS regions +- Support for single image promotion +- Support for multiple images using matrix configuration +- Multi-arch manifest support with `--all` flag +- 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. +| `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 | +| `skopeo_additional_args` | Extra args for skopeo copy (e.g. `"--all"` to copy multi-arch lists) | No | + +## 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 + skopeo_additional_args: "--all" +``` + +### 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" + } + ] + skopeo_additional_args: "--all" +``` + +### 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 }} +``` + +## Multi-Architecture Support + +To copy multi-architecture manifests (e.g., amd64, arm64), use the `--all` flag: + +```yaml +- uses: ./.github/actions/promote-image + with: + # ... other inputs ... + skopeo_additional_args: "--all" +``` + +## How It Works + +1. Installs `skopeo` and `jq` on the runner +2. Assumes the source IAM role and retrieves ECR credentials +3. Assumes the destination IAM role and retrieves ECR credentials +4. Uses `skopeo copy` to transfer the image(s) between registries +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) + +## 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 you're using the `--all` flag in `skopeo_additional_args`. diff --git a/actions/promote-image-ecr/action.yaml b/actions/promote-image-ecr/action.yaml new file mode 100644 index 00000000..78a8ec53 --- /dev/null +++ b/actions/promote-image-ecr/action.yaml @@ -0,0 +1,276 @@ +name: Promote image between ECR registries +description: | + Promote an image from one ECR registry to another, using skopeo under the hood. + 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: source_repository, destination_repository, source_tag, destination_tag. + Example: [{"source_repository":"app1","destination_repository":"app1","source_tag":"v1.0","destination_tag":"v1.0"}] + If provided, this takes precedence over individual source_repository/destination_repository/source_tag/destination_tag inputs. + required: false + default: "" + skopeo_additional_args: + description: Extra args for skopeo copy (e.g. "--all" to copy multi-arch lists) + required: false + default: "" + +runs: + using: "composite" + steps: + - name: Install skopeo and jq + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y skopeo jq + + - 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: Get SOURCE ECR login password + id: src + shell: bash + run: | + set -euo pipefail + REGION="${{ inputs.source_aws_region || inputs.aws_region }}" + PASS=$(aws ecr get-login-password) + echo "::add-mask::$PASS" + echo "password=$PASS" >> "$GITHUB_OUTPUT" + + - 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: Get DESTINATION ECR login password + id: dst + shell: bash + run: | + set -euo pipefail + REGION="${{ inputs.destination_aws_region || inputs.aws_region }}" + PASS=$(aws ecr get-login-password) + echo "::add-mask::$PASS" + echo "password=$PASS" >> "$GITHUB_OUTPUT" + + - name: Copy image with skopeo + shell: bash + env: + SRC_PASS: ${{ steps.src.outputs.password }} + DST_PASS: ${{ steps.dst.outputs.password }} + run: | + 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 "" >> "$RESULTS_FILE" + + # Initialize JSON results + echo '{"promotions": []}' > "$RESULTS_JSON" + + # Check if images matrix is provided + if [[ -n "${{ inputs.images }}" ]]; 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 '${{ inputs.images }}' | 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://${{ inputs.source_registry }}/${SRC_REPO}:${SRC_TAG}" + DST="docker://${{ inputs.destination_registry }}/${DST_REPO}:${DST_TAG}" + + echo "----------------------------------------" + echo "Copying:" + echo " FROM: ${SRC}" + echo " TO: ${DST}" + + START_TIME=$(date +%s) + if skopeo copy \ + ${{ inputs.skopeo_additional_args }} \ + --src-creds "AWS:${SRC_PASS}" \ + --dest-creds "AWS:${DST_PASS}" \ + "${SRC}" \ + "${DST}"; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + echo "✓ Successfully copied ${SRC_REPO}:${SRC_TAG} (took ${DURATION}s)" + + # Append to markdown + echo "### ✅ ${SRC_REPO}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" + echo "- **Source Region:** \`${{ inputs.source_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" + echo "- **Destination Region:** \`${{ inputs.destination_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" + echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" + echo "- **Status:** Success" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + # Append to JSON + 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 "success" \ + '.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" + + IMAGE_COUNT=$((IMAGE_COUNT + 1)) + else + echo "✗ Failed to copy ${SRC_REPO}:${SRC_TAG}" + + # Append failure to markdown + echo "### ❌ ${SRC_REPO}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" + echo "- **Status:** Failed" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + 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 (backward compatibility) + SRC="docker://${{ inputs.source_registry }}/${{ inputs.source_repository }}:${{ inputs.source_tag }}" + DST="docker://${{ inputs.destination_registry }}/${{ inputs.destination_repository }}:${{ inputs.destination_tag }}" + + echo "Copying:" + echo " FROM: ${SRC}" + echo " TO: ${DST}" + + echo "## Promoted Image" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + START_TIME=$(date +%s) + # Notes: + # - username for ECR basic auth is always "AWS" + # - add "--all" if you want to copy multi-arch manifests too + if skopeo copy \ + ${{ inputs.skopeo_additional_args }} \ + --src-creds "AWS:${SRC_PASS}" \ + --dest-creds "AWS:${DST_PASS}" \ + "${SRC}" \ + "${DST}"; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + echo "✓ Successfully copied ${{ inputs.source_repository }}:${{ inputs.source_tag }} (took ${DURATION}s)" + + # Write to markdown + echo "### ✅ ${{ inputs.source_repository }}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${{ inputs.source_repository }}:${{ inputs.source_tag }}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${{ inputs.destination_repository }}:${{ inputs.destination_tag }}\`" >> "$RESULTS_FILE" + echo "- **Source Region:** \`${{ inputs.source_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" + echo "- **Destination Region:** \`${{ inputs.destination_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" + echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" + echo "- **Status:** Success" >> "$RESULTS_FILE" + + # Write to JSON + jq --arg src_repo "${{ inputs.source_repository }}" \ + --arg src_tag "${{ inputs.source_tag }}" \ + --arg dst_repo "${{ inputs.destination_repository }}" \ + --arg dst_tag "${{ inputs.destination_tag }}" \ + --arg duration "$DURATION" \ + --arg status "success" \ + '.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" + else + echo "✗ Failed to copy ${{ inputs.source_repository }}:${{ inputs.source_tag }}" + + echo "### ❌ ${{ inputs.source_repository }}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${{ inputs.source_repository }}:${{ inputs.source_tag }}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${{ inputs.destination_repository }}:${{ inputs.destination_tag }}\`" >> "$RESULTS_FILE" + echo "- **Status:** Failed" >> "$RESULTS_FILE" + + exit 1 + fi + fi + + - 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 \ No newline at end of file diff --git a/actions/promote-image-ecr/package.json b/actions/promote-image-ecr/package.json new file mode 100644 index 00000000..6e0b1a5d --- /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" +} From 5a68e546afd48bd8780c806a0919545d6abb9ede Mon Sep 17 00:00:00 2001 From: kdihalas Date: Wed, 18 Feb 2026 14:42:35 +0000 Subject: [PATCH 2/5] chore: move main logic to separate file --- actions/promote-image-ecr/README.md | 71 +++--- actions/promote-image-ecr/action.yaml | 224 ++++-------------- .../scripts/promote-images.sh | 163 +++++++++++++ 3 files changed, 253 insertions(+), 205 deletions(-) create mode 100755 actions/promote-image-ecr/scripts/promote-images.sh diff --git a/actions/promote-image-ecr/README.md b/actions/promote-image-ecr/README.md index 09141224..e0a3b442 100644 --- a/actions/promote-image-ecr/README.md +++ b/actions/promote-image-ecr/README.md @@ -1,6 +1,8 @@ # Promote Image Action -Promote Docker images from one Amazon ECR registry to another using [skopeo](https://github.com/containers/skopeo). The action assumes the provided IAM roles to access both source and destination registries. +Promote Docker images from one Amazon ECR registry to another using +[skopeo](https://github.com/containers/skopeo). The action assumes the provided +IAM roles to access both source and destination registries. ## Features @@ -15,29 +17,35 @@ Promote Docker images from one Amazon ECR registry to another using [skopeo](htt ## 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.) + - 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. -| `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 | -| `skopeo_additional_args` | Extra args for skopeo copy (e.g. `"--all"` to copy multi-arch lists) | No | +| 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. | `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 | | `skopeo_additional_args` | Extra args for skopeo copy (e.g. `"--all"` +to copy multi-arch lists) | No | ## Usage @@ -56,7 +64,7 @@ jobs: contents: read steps: - uses: actions/checkout@v4 - + - name: Promote image uses: ./.github/actions/promote-image with: @@ -88,7 +96,7 @@ jobs: contents: read steps: - uses: actions/checkout@v4 - + - name: Promote multiple images uses: ./.github/actions/promote-image with: @@ -145,7 +153,7 @@ jobs: - { repo: "service-x", tag: "sha-abc123" } steps: - uses: actions/checkout@v4 - + - name: Promote ${{ matrix.image.repo }} uses: ./.github/actions/promote-image with: @@ -190,6 +198,7 @@ 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 @@ -203,9 +212,11 @@ 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. +Artifacts are retained for 30 days and can be downloaded for auditing or +integration with other tools. **Example JSON structure:** + ```json { "promotions": [ @@ -226,15 +237,19 @@ Artifacts are retained for 30 days and can be downloaded for auditing or integra - 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) +- 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) ## Troubleshooting ### Image Not Found -Verify the source repository name and tag are correct, and that the image exists in the source registry. +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 you're using the `--all` flag in `skopeo_additional_args`. +If you're seeing issues with multi-architecture images, ensure you're using the +`--all` flag in `skopeo_additional_args`. diff --git a/actions/promote-image-ecr/action.yaml b/actions/promote-image-ecr/action.yaml index 78a8ec53..41651957 100644 --- a/actions/promote-image-ecr/action.yaml +++ b/actions/promote-image-ecr/action.yaml @@ -5,34 +5,50 @@ description: | inputs: aws_region: - description: "AWS region for BOTH registries. Use source_aws_region and destination_aws_region instead." + 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." + 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." + 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)" + 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) + 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 + 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 + 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) + 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) + 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) + 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) @@ -45,7 +61,8 @@ inputs: required: false default: "" skopeo_additional_args: - description: Extra args for skopeo copy (e.g. "--all" to copy multi-arch lists) + description: + Extra args for skopeo copy (e.g. "--all" to copy multi-arch lists) required: false default: "" @@ -67,9 +84,10 @@ runs: - name: Get SOURCE ECR login password id: src shell: bash + env: + REGION: ${{ inputs.source_aws_region || inputs.aws_region }} run: | set -euo pipefail - REGION="${{ inputs.source_aws_region || inputs.aws_region }}" PASS=$(aws ecr get-login-password) echo "::add-mask::$PASS" echo "password=$PASS" >> "$GITHUB_OUTPUT" @@ -79,13 +97,14 @@ runs: with: role-to-assume: ${{ inputs.destination_role_arn }} aws-region: ${{ inputs.destination_aws_region || inputs.aws_region }} - + - name: Get DESTINATION ECR login password id: dst shell: bash + env: + REGION: ${{ inputs.destination_aws_region || inputs.aws_region }} run: | set -euo pipefail - REGION="${{ inputs.destination_aws_region || inputs.aws_region }}" PASS=$(aws ecr get-login-password) echo "::add-mask::$PASS" echo "password=$PASS" >> "$GITHUB_OUTPUT" @@ -93,171 +112,22 @@ runs: - name: Copy image with skopeo 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 }} + ACTION_PATH: ${{ github.action_path }} run: | - 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 "" >> "$RESULTS_FILE" - - # Initialize JSON results - echo '{"promotions": []}' > "$RESULTS_JSON" - - # Check if images matrix is provided - if [[ -n "${{ inputs.images }}" ]]; 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 '${{ inputs.images }}' | 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://${{ inputs.source_registry }}/${SRC_REPO}:${SRC_TAG}" - DST="docker://${{ inputs.destination_registry }}/${DST_REPO}:${DST_TAG}" - - echo "----------------------------------------" - echo "Copying:" - echo " FROM: ${SRC}" - echo " TO: ${DST}" - - START_TIME=$(date +%s) - if skopeo copy \ - ${{ inputs.skopeo_additional_args }} \ - --src-creds "AWS:${SRC_PASS}" \ - --dest-creds "AWS:${DST_PASS}" \ - "${SRC}" \ - "${DST}"; then - END_TIME=$(date +%s) - DURATION=$((END_TIME - START_TIME)) - - echo "✓ Successfully copied ${SRC_REPO}:${SRC_TAG} (took ${DURATION}s)" - - # Append to markdown - echo "### ✅ ${SRC_REPO}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" - echo "- **Source Region:** \`${{ inputs.source_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" - echo "- **Destination Region:** \`${{ inputs.destination_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" - echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" - echo "- **Status:** Success" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - - # Append to JSON - 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 "success" \ - '.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" - - IMAGE_COUNT=$((IMAGE_COUNT + 1)) - else - echo "✗ Failed to copy ${SRC_REPO}:${SRC_TAG}" - - # Append failure to markdown - echo "### ❌ ${SRC_REPO}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" - echo "- **Status:** Failed" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - - 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 (backward compatibility) - SRC="docker://${{ inputs.source_registry }}/${{ inputs.source_repository }}:${{ inputs.source_tag }}" - DST="docker://${{ inputs.destination_registry }}/${{ inputs.destination_repository }}:${{ inputs.destination_tag }}" - - echo "Copying:" - echo " FROM: ${SRC}" - echo " TO: ${DST}" - - echo "## Promoted Image" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - - START_TIME=$(date +%s) - # Notes: - # - username for ECR basic auth is always "AWS" - # - add "--all" if you want to copy multi-arch manifests too - if skopeo copy \ - ${{ inputs.skopeo_additional_args }} \ - --src-creds "AWS:${SRC_PASS}" \ - --dest-creds "AWS:${DST_PASS}" \ - "${SRC}" \ - "${DST}"; then - END_TIME=$(date +%s) - DURATION=$((END_TIME - START_TIME)) - - echo "✓ Successfully copied ${{ inputs.source_repository }}:${{ inputs.source_tag }} (took ${DURATION}s)" - - # Write to markdown - echo "### ✅ ${{ inputs.source_repository }}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${{ inputs.source_repository }}:${{ inputs.source_tag }}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${{ inputs.destination_repository }}:${{ inputs.destination_tag }}\`" >> "$RESULTS_FILE" - echo "- **Source Region:** \`${{ inputs.source_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" - echo "- **Destination Region:** \`${{ inputs.destination_aws_region || inputs.aws_region }}\`" >> "$RESULTS_FILE" - echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" - echo "- **Status:** Success" >> "$RESULTS_FILE" - - # Write to JSON - jq --arg src_repo "${{ inputs.source_repository }}" \ - --arg src_tag "${{ inputs.source_tag }}" \ - --arg dst_repo "${{ inputs.destination_repository }}" \ - --arg dst_tag "${{ inputs.destination_tag }}" \ - --arg duration "$DURATION" \ - --arg status "success" \ - '.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" - else - echo "✗ Failed to copy ${{ inputs.source_repository }}:${{ inputs.source_tag }}" - - echo "### ❌ ${{ inputs.source_repository }}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${{ inputs.source_repository }}:${{ inputs.source_tag }}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${{ inputs.destination_repository }}:${{ inputs.destination_tag }}\`" >> "$RESULTS_FILE" - echo "- **Status:** Failed" >> "$RESULTS_FILE" - - exit 1 - fi - fi + "${ACTION_PATH}/scripts/promote-images.sh" - name: Generate Job Summary if: always() @@ -273,4 +143,4 @@ runs: with: name: promotion-results-${{ github.run_id }} path: /tmp/promotion-results/ - retention-days: 30 \ No newline at end of file + retention-days: 30 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 00000000..8a44b0bd --- /dev/null +++ b/actions/promote-image-ecr/scripts/promote-images.sh @@ -0,0 +1,163 @@ +#!/bin/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 "" >> "$RESULTS_FILE" + +# Initialize JSON results +echo '{"promotions": []}' > "$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 "----------------------------------------" + echo "Copying:" + echo " FROM: ${SRC}" + echo " TO: ${DST}" + + START_TIME=$(date +%s) + if skopeo copy \ + $SKOPEO_ARGS \ + --src-creds "AWS:${SRC_PASS}" \ + --dest-creds "AWS:${DST_PASS}" \ + "${SRC}" \ + "${DST}"; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + echo "✓ Successfully copied ${SRC_REPO}:${SRC_TAG} (took ${DURATION}s)" + + # Append to markdown + echo "### ✅ ${SRC_REPO}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" + echo "- **Source Region:** \`${SOURCE_AWS_REGION}\`" >> "$RESULTS_FILE" + echo "- **Destination Region:** \`${DESTINATION_AWS_REGION}\`" >> "$RESULTS_FILE" + echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" + echo "- **Status:** Success" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + # Append to JSON + 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 "success" \ + '.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" + + IMAGE_COUNT=$((IMAGE_COUNT + 1)) + else + echo "✗ Failed to copy ${SRC_REPO}:${SRC_TAG}" + + # Append failure to markdown + echo "### ❌ ${SRC_REPO}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" + echo "- **Status:** Failed" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + 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:" + echo " FROM: ${SRC}" + echo " TO: ${DST}" + + echo "## Promoted Image" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + + START_TIME=$(date +%s) + # Notes: + # - username for ECR basic auth is always "AWS" + # - add "--all" if you want to copy multi-arch manifests too + if skopeo copy \ + $SKOPEO_ARGS \ + --src-creds "AWS:${SRC_PASS}" \ + --dest-creds "AWS:${DST_PASS}" \ + "${SRC}" \ + "${DST}"; then + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + echo "✓ Successfully copied ${SOURCE_REPOSITORY}:${SOURCE_TAG} (took ${DURATION}s)" + + # Write to markdown + echo "### ✅ ${SOURCE_REPOSITORY}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${SOURCE_REPOSITORY}:${SOURCE_TAG}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${DESTINATION_REPOSITORY}:${DESTINATION_TAG}\`" >> "$RESULTS_FILE" + echo "- **Source Region:** \`${SOURCE_AWS_REGION}\`" >> "$RESULTS_FILE" + echo "- **Destination Region:** \`${DESTINATION_AWS_REGION}\`" >> "$RESULTS_FILE" + echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" + echo "- **Status:** Success" >> "$RESULTS_FILE" + + # Write to JSON + jq --arg src_repo "$SOURCE_REPOSITORY" \ + --arg src_tag "$SOURCE_TAG" \ + --arg dst_repo "$DESTINATION_REPOSITORY" \ + --arg dst_tag "$DESTINATION_TAG" \ + --arg duration "$DURATION" \ + --arg status "success" \ + '.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" + else + echo "✗ Failed to copy ${SOURCE_REPOSITORY}:${SOURCE_TAG}" + + echo "### ❌ ${SOURCE_REPOSITORY}" >> "$RESULTS_FILE" + echo "" >> "$RESULTS_FILE" + echo "- **Source:** \`${SOURCE_REPOSITORY}:${SOURCE_TAG}\`" >> "$RESULTS_FILE" + echo "- **Destination:** \`${DESTINATION_REPOSITORY}:${DESTINATION_TAG}\`" >> "$RESULTS_FILE" + echo "- **Status:** Failed" >> "$RESULTS_FILE" + + exit 1 + fi +fi From 7e3d52e30f3471236e609add46fad372e8959772 Mon Sep 17 00:00:00 2001 From: kdihalas Date: Wed, 18 Feb 2026 14:51:29 +0000 Subject: [PATCH 3/5] chore: add changeset --- .changeset/ninety-boxes-vanish.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/ninety-boxes-vanish.md diff --git a/.changeset/ninety-boxes-vanish.md b/.changeset/ninety-boxes-vanish.md new file mode 100644 index 00000000..a9b24828 --- /dev/null +++ b/.changeset/ninety-boxes-vanish.md @@ -0,0 +1,5 @@ +--- +"promote-image-ecr": minor +--- + +create promote-image-ecr From 6cdcbca5e3b0c34fee4143d0430616a4edb392e7 Mon Sep 17 00:00:00 2001 From: kdihalas Date: Wed, 18 Feb 2026 15:23:15 +0000 Subject: [PATCH 4/5] feat: add support for signed containers --- actions/promote-image-ecr/README.md | 78 +++++++++++++++---- actions/promote-image-ecr/action.yaml | 18 ++++- .../scripts/promote-images.sh | 52 ++++++++++--- 3 files changed, 116 insertions(+), 32 deletions(-) diff --git a/actions/promote-image-ecr/README.md b/actions/promote-image-ecr/README.md index e0a3b442..3f5e57c6 100644 --- a/actions/promote-image-ecr/README.md +++ b/actions/promote-image-ecr/README.md @@ -1,18 +1,21 @@ # Promote Image Action Promote Docker images from one Amazon ECR registry to another using +[cosign](https://github.com/sigstore/cosign) (default) or [skopeo](https://github.com/containers/skopeo). The action assumes the provided IAM roles to access both source and destination registries. ## Features - Copy images between ECR registries in the same or different AWS regions +- **Copy signatures and attestations using cosign** (default) - Support for single image promotion - Support for multiple images using matrix configuration -- Multi-arch manifest support with `--all` flag +- 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) +- Optional fallback to skopeo for compatibility ## Prerequisites @@ -34,18 +37,19 @@ IAM roles to access both source and destination registries. | `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. | `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 | | `skopeo_additional_args` | Extra args for skopeo copy (e.g. `"--all"` -to copy multi-arch lists) | No | +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 | +| `use_cosign` | Use cosign to copy images (includes signatures and attestations). Set to `'false'` to use skopeo instead. | No | +| `skopeo_additional_args` | Extra args for skopeo copy (e.g. `"--all"` to copy multi-arch lists). Only used when `use_cosign` is `'false'`. | No | ## Usage @@ -169,23 +173,63 @@ jobs: destination_tag: ${{ matrix.image.tag }} ``` +## Copy Tool: Cosign vs Skopeo + +### Cosign + +By default, the action uses [cosign](https://github.com/sigstore/cosign) to copy +images. This provides: + +- **Signature and attestation copying**: Automatically includes all signatures + and attestations +- **Multi-architecture support**: Copies all architectures by default +- **Better compatibility**: Works with modern signing workflows (Sigstore, + cosign) + +### Skopeo (Fallback) + +You can use [skopeo](https://github.com/containers/skopeo) instead by setting +`use_cosign: 'false'`: + +```yaml +- uses: ./.github/actions/promote-image-ecr + with: + # ... other inputs ... + use_cosign: "false" + skopeo_additional_args: "--all" # For multi-arch support +``` + +Use skopeo when: + +- You need specific skopeo features or arguments +- You want to use sigstore attachments with skopeo +- Compatibility with older workflows + ## Multi-Architecture Support -To copy multi-architecture manifests (e.g., amd64, arm64), use the `--all` flag: +**With cosign (default)**: Multi-architecture manifests are automatically copied +with all architectures included. + +**With skopeo**: Add the `--all` flag via `skopeo_additional_args`: ```yaml -- uses: ./.github/actions/promote-image +- uses: ./.github/actions/promote-image-ecr with: # ... other inputs ... + use_cosign: "false" skopeo_additional_args: "--all" ``` ## How It Works -1. Installs `skopeo` and `jq` on the runner +1. Installs `cosign`, `skopeo`, and `jq` on the runner 2. Assumes the source IAM role and retrieves ECR credentials 3. Assumes the destination IAM role and retrieves ECR credentials -4. Uses `skopeo copy` to transfer the image(s) between registries +4. Uses `cosign copy` (default) or `skopeo copy` to transfer the image(s) + between registries + - **cosign**: Copies image, all architectures, signatures, and attestations + - **skopeo**: Copies image with optional flags for multi-arch and sigstore + attachments 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 diff --git a/actions/promote-image-ecr/action.yaml b/actions/promote-image-ecr/action.yaml index 41651957..feb537ea 100644 --- a/actions/promote-image-ecr/action.yaml +++ b/actions/promote-image-ecr/action.yaml @@ -1,6 +1,6 @@ name: Promote image between ECR registries description: | - Promote an image from one ECR registry to another, using skopeo under the hood. + 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: @@ -62,9 +62,15 @@ inputs: default: "" skopeo_additional_args: description: - Extra args for skopeo copy (e.g. "--all" to copy multi-arch lists) + "Extra args for skopeo copy (default: --all to copy multi-arch lists)." required: false - default: "" + default: "--all" + copy_signatures: + description: + Use cosign to copy images (includes signatures and attestations). Set to + 'false' to use skopeo instead. + required: false + default: "true" runs: using: "composite" @@ -75,6 +81,11 @@ runs: sudo apt-get update sudo apt-get install -y skopeo jq + - 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: @@ -125,6 +136,7 @@ runs: ${{ 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" diff --git a/actions/promote-image-ecr/scripts/promote-images.sh b/actions/promote-image-ecr/scripts/promote-images.sh index 8a44b0bd..fb2c437f 100755 --- a/actions/promote-image-ecr/scripts/promote-images.sh +++ b/actions/promote-image-ecr/scripts/promote-images.sh @@ -9,11 +9,49 @@ 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:** $(if [[ "$COPY_SIGNATURES" == "true" ]]; then echo "cosign"; else echo "skopeo"; fi)" >> "$RESULTS_FILE" echo "" >> "$RESULTS_FILE" # Initialize JSON results echo '{"promotions": []}' > "$RESULTS_JSON" +# Function to copy image using the selected tool +copy_image() { + local src="$1" + local dst="$2" + + if [[ "$COPY_SIGNATURES" == "true" ]]; then + # Use cosign with multi-arch support + # cosign uses docker config for authentication + export DOCKER_CONFIG=/tmp/.docker + mkdir -p "$DOCKER_CONFIG" + + # Create docker config for source registry + cat > "$DOCKER_CONFIG/config.json" < Date: Thu, 19 Feb 2026 14:49:38 +0000 Subject: [PATCH 5/5] refactor based on feedback --- actions/promote-image-ecr/README.md | 111 ++++------ actions/promote-image-ecr/action.yaml | 117 +++++----- .../scripts/promote-images.sh | 207 ++++++++---------- 3 files changed, 184 insertions(+), 251 deletions(-) diff --git a/actions/promote-image-ecr/README.md b/actions/promote-image-ecr/README.md index 3f5e57c6..aa6ba8c5 100644 --- a/actions/promote-image-ecr/README.md +++ b/actions/promote-image-ecr/README.md @@ -1,21 +1,20 @@ -# Promote Image Action +# Promote Image ECR Action Promote Docker images from one Amazon ECR registry to another using -[cosign](https://github.com/sigstore/cosign) (default) or -[skopeo](https://github.com/containers/skopeo). The action assumes the provided -IAM roles to access both source and destination registries. +[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** (default) +- **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) -- Optional fallback to skopeo for compatibility ## Prerequisites @@ -30,26 +29,30 @@ IAM roles to access both source and destination registries. | 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 | +| `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` +\*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 | -| `use_cosign` | Use cosign to copy images (includes signatures and attestations). Set to `'false'` to use skopeo instead. | No | -| `skopeo_additional_args` | Extra args for skopeo copy (e.g. `"--all"` to copy multi-arch lists). Only used when `use_cosign` is `'false'`. | No | +| 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 @@ -82,7 +85,6 @@ jobs: destination_repository: my-app source_tag: v1.0.0 destination_tag: v1.0.0 - skopeo_additional_args: "--all" ``` ### Multiple Images Promotion (Matrix) @@ -131,7 +133,6 @@ jobs: "destination_tag": "production" } ] - skopeo_additional_args: "--all" ``` ### Using with GitHub Matrix Strategy @@ -173,63 +174,24 @@ jobs: destination_tag: ${{ matrix.image.tag }} ``` -## Copy Tool: Cosign vs Skopeo +## Copy Tool -### Cosign - -By default, the action uses [cosign](https://github.com/sigstore/cosign) to copy -images. This provides: +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 -- **Better compatibility**: Works with modern signing workflows (Sigstore, +- **Modern compatibility**: Works with modern signing workflows (Sigstore, cosign) -### Skopeo (Fallback) - -You can use [skopeo](https://github.com/containers/skopeo) instead by setting -`use_cosign: 'false'`: - -```yaml -- uses: ./.github/actions/promote-image-ecr - with: - # ... other inputs ... - use_cosign: "false" - skopeo_additional_args: "--all" # For multi-arch support -``` - -Use skopeo when: - -- You need specific skopeo features or arguments -- You want to use sigstore attachments with skopeo -- Compatibility with older workflows - -## Multi-Architecture Support - -**With cosign (default)**: Multi-architecture manifests are automatically copied -with all architectures included. - -**With skopeo**: Add the `--all` flag via `skopeo_additional_args`: - -```yaml -- uses: ./.github/actions/promote-image-ecr - with: - # ... other inputs ... - use_cosign: "false" - skopeo_additional_args: "--all" -``` - ## How It Works -1. Installs `cosign`, `skopeo`, and `jq` on the runner +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` (default) or `skopeo copy` to transfer the image(s) - between registries - - **cosign**: Copies image, all architectures, signatures, and attestations - - **skopeo**: Copies image with optional flags for multi-arch and sigstore - attachments +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 @@ -285,6 +247,8 @@ integration with other tools. 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 @@ -295,5 +259,6 @@ in the source registry. ### Multi-Arch Issues -If you're seeing issues with multi-architecture images, ensure you're using the -`--all` flag in `skopeo_additional_args`. +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 index feb537ea..892b6a5d 100644 --- a/actions/promote-image-ecr/action.yaml +++ b/actions/promote-image-ecr/action.yaml @@ -4,83 +4,79 @@ description: | The action will assume the provided IAM roles to access the source and destination registries. inputs: - aws_region: + aws-region: description: "AWS region for BOTH registries. Use source_aws_region and - destination_aws_region instead." + destination-aws-region instead." required: false - source_aws_region: + source-aws-region: description: "AWS region for source registry. Example: eu-west-1. Falls back to - aws_region if not provided." + aws-region if not provided." required: false - destination_aws_region: + destination-aws-region: description: "AWS region for destination registry. Example: us-east-1. Falls back to - aws_region if not provided." + aws-region if not provided." required: false - source_role_arn: + source-role-arn: description: "IAM Role ARN to assume in SOURCE account (needs ECR read permissions)" required: true - destination_role_arn: + destination-role-arn: description: IAM Role ARN to assume in DEST account (needs ECR write permissions) required: true - source_registry: + source-registry: description: Source registry host, e.g. 111111111111.dkr.ecr.eu-west-1.amazonaws.com required: true - destination_registry: + destination-registry: description: Destination registry host, e.g. 222222222222.dkr.ecr.eu-west-1.amazonaws.com required: true - source_repository: + source-repository: description: Source repository name, e.g. my-app (not required if using images matrix) required: false - destination_repository: + destination-repository: description: Destination repository name, e.g. my-app (not required if using images matrix) required: false - source_tag: + source-tag: description: Source tag (or digest if you use @sha256:... in advanced mode) (not required if using images matrix) required: false - destination_tag: + 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: source_repository, destination_repository, source_tag, destination_tag. - Example: [{"source_repository":"app1","destination_repository":"app1","source_tag":"v1.0","destination_tag":"v1.0"}] - If provided, this takes precedence over individual source_repository/destination_repository/source_tag/destination_tag inputs. + 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: "" - skopeo_additional_args: - description: - "Extra args for skopeo copy (default: --all to copy multi-arch lists)." - required: false - default: "--all" - copy_signatures: + 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 skopeo and jq - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y skopeo jq - - name: Install cosign uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 with: @@ -89,54 +85,40 @@ runs: - 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 }} + role-to-assume: ${{ inputs.source-role-arn }} + aws-region: ${{ inputs.source-aws-region || inputs.aws-region }} - - name: Get SOURCE ECR login password + - name: Login to Amazon ECR (SOURCE) id: src - shell: bash - env: - REGION: ${{ inputs.source_aws_region || inputs.aws_region }} - run: | - set -euo pipefail - PASS=$(aws ecr get-login-password) - echo "::add-mask::$PASS" - echo "password=$PASS" >> "$GITHUB_OUTPUT" + 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 }} + role-to-assume: ${{ inputs.destination-role-arn }} + aws-region: ${{ inputs.destination-aws-region || inputs.aws-region }} - - name: Get DESTINATION ECR login password + - name: Login to Amazon ECR (DESTINATION) id: dst - shell: bash - env: - REGION: ${{ inputs.destination_aws_region || inputs.aws_region }} - run: | - set -euo pipefail - PASS=$(aws ecr get-login-password) - echo "::add-mask::$PASS" - echo "password=$PASS" >> "$GITHUB_OUTPUT" + uses: aws-actions/amazon-ecr-login@v2 - - name: Copy image with skopeo + - 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 }} + 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 }} + 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 }} + ${{ inputs.destination-aws-region || inputs.aws-region }} SRC_PASS: ${{ steps.src.outputs.password }} DST_PASS: ${{ steps.dst.outputs.password }} - COPY_SIGNATURES: ${{ inputs.copy_signatures }} + COPY_SIGNATURES: ${{ inputs.copy-signatures }} ACTION_PATH: ${{ github.action_path }} run: | "${ACTION_PATH}/scripts/promote-images.sh" @@ -156,3 +138,14 @@ runs: 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/scripts/promote-images.sh b/actions/promote-image-ecr/scripts/promote-images.sh index fb2c437f..fc76653b 100755 --- a/actions/promote-image-ecr/scripts/promote-images.sh +++ b/actions/promote-image-ecr/scripts/promote-images.sh @@ -1,4 +1,5 @@ -#!/bin/bash +#!/usr/bin/env bash + set -euo pipefail # Create results directory and file @@ -9,49 +10,80 @@ 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:** $(if [[ "$COPY_SIGNATURES" == "true" ]]; then echo "cosign"; else echo "skopeo"; fi)" >> "$RESULTS_FILE" +echo "**Copy Tool:** cosign" >> "$RESULTS_FILE" echo "" >> "$RESULTS_FILE" # Initialize JSON results echo '{"promotions": []}' > "$RESULTS_JSON" -# Function to copy image using the selected tool +# Function to copy image using cosign only copy_image() { local src="$1" local dst="$2" - - if [[ "$COPY_SIGNATURES" == "true" ]]; then - # Use cosign with multi-arch support - # cosign uses docker config for authentication - export DOCKER_CONFIG=/tmp/.docker - mkdir -p "$DOCKER_CONFIG" - - # Create docker config for source registry - cat > "$DOCKER_CONFIG/config.json" <> "$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 @@ -59,7 +91,7 @@ if [[ -n "$IMAGES_JSON" ]]; then 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') @@ -70,63 +102,35 @@ if [[ -n "$IMAGES_JSON" ]]; then SRC="docker://${SOURCE_REGISTRY}/${SRC_REPO}:${SRC_TAG}" DST="docker://${DESTINATION_REGISTRY}/${DST_REPO}:${DST_TAG}" - echo "----------------------------------------" - echo "Copying:" - echo " FROM: ${SRC}" - echo " TO: ${DST}" - + 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_REPO}:${SRC_TAG} (took ${DURATION}s)" - + + echo "✓ Successfully copied ${SRC} to ${DST} (took ${DURATION}s)" + # Append to markdown - echo "### ✅ ${SRC_REPO}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" - echo "- **Source Region:** \`${SOURCE_AWS_REGION}\`" >> "$RESULTS_FILE" - echo "- **Destination Region:** \`${DESTINATION_AWS_REGION}\`" >> "$RESULTS_FILE" - echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" - echo "- **Status:** Success" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - + write_markdown_result "$SRC_REPO" "$SRC_TAG" "$DST_REPO" "$DST_TAG" "$SOURCE_AWS_REGION" "$DESTINATION_AWS_REGION" "$DURATION" "success" # Append to JSON - 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 "success" \ - '.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" - + write_promotion_json "$SRC_REPO" "$SRC_TAG" "$DST_REPO" "$DST_TAG" "$DURATION" "success" + IMAGE_COUNT=$((IMAGE_COUNT + 1)) else - echo "✗ Failed to copy ${SRC_REPO}:${SRC_TAG}" - + echo "✗ Failed to copy ${SRC} to ${DST}" + # Append failure to markdown - echo "### ❌ ${SRC_REPO}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${SRC_REPO}:${SRC_TAG}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${DST_REPO}:${DST_TAG}\`" >> "$RESULTS_FILE" - echo "- **Status:** Failed" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - + 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" @@ -135,57 +139,28 @@ else SRC="docker://${SOURCE_REGISTRY}/${SOURCE_REPOSITORY}:${SOURCE_TAG}" DST="docker://${DESTINATION_REGISTRY}/${DESTINATION_REPOSITORY}:${DESTINATION_TAG}" - echo "Copying:" - echo " FROM: ${SRC}" - echo " TO: ${DST}" + echo "-> Copying ${SRC} to ${DST}" echo "## Promoted Image" >> "$RESULTS_FILE" echo "" >> "$RESULTS_FILE" - + START_TIME=$(date +%s) - # Notes: - # - username for ECR basic auth is always "AWS" - # - add "--all" if you want to copy multi-arch manifests too if copy_image "${SRC}" "${DST}"; then END_TIME=$(date +%s) DURATION=$((END_TIME - START_TIME)) - - echo "✓ Successfully copied ${SOURCE_REPOSITORY}:${SOURCE_TAG} (took ${DURATION}s)" - + echo "✓ Successfully copied ${SRC} to ${DST} (took ${DURATION}s)" + # Write to markdown - echo "### ✅ ${SOURCE_REPOSITORY}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${SOURCE_REPOSITORY}:${SOURCE_TAG}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${DESTINATION_REPOSITORY}:${DESTINATION_TAG}\`" >> "$RESULTS_FILE" - echo "- **Source Region:** \`${SOURCE_AWS_REGION}\`" >> "$RESULTS_FILE" - echo "- **Destination Region:** \`${DESTINATION_AWS_REGION}\`" >> "$RESULTS_FILE" - echo "- **Duration:** ${DURATION}s" >> "$RESULTS_FILE" - echo "- **Status:** Success" >> "$RESULTS_FILE" - + write_markdown_result "$SOURCE_REPOSITORY" "$SOURCE_TAG" "$DESTINATION_REPOSITORY" "$DESTINATION_TAG" "$SOURCE_AWS_REGION" "$DESTINATION_AWS_REGION" "$DURATION" "success" # Write to JSON - jq --arg src_repo "$SOURCE_REPOSITORY" \ - --arg src_tag "$SOURCE_TAG" \ - --arg dst_repo "$DESTINATION_REPOSITORY" \ - --arg dst_tag "$DESTINATION_TAG" \ - --arg duration "$DURATION" \ - --arg status "success" \ - '.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" + write_promotion_json "$SOURCE_REPOSITORY" "$SOURCE_TAG" "$DESTINATION_REPOSITORY" "$DESTINATION_TAG" "$DURATION" "success" else - echo "✗ Failed to copy ${SOURCE_REPOSITORY}:${SOURCE_TAG}" - - echo "### ❌ ${SOURCE_REPOSITORY}" >> "$RESULTS_FILE" - echo "" >> "$RESULTS_FILE" - echo "- **Source:** \`${SOURCE_REPOSITORY}:${SOURCE_TAG}\`" >> "$RESULTS_FILE" - echo "- **Destination:** \`${DESTINATION_REPOSITORY}:${DESTINATION_TAG}\`" >> "$RESULTS_FILE" - echo "- **Status:** Failed" >> "$RESULTS_FILE" - + 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