Mirror External Images #666
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Mirror External Images | |
| # SECURITY NOTES: | |
| # 1. This workflow handles sensitive secrets (registry credentials, private keys) | |
| # 2. Command injection mitigations: | |
| # - All untrusted inputs (branch names, etc.) are passed via environment variables | |
| # - Never use ${{ }} syntax directly in bash contexts with untrusted data | |
| # 3. workflow_run validation: | |
| # - Only accepts triggers from this repository (not forks) | |
| # - Validates repository source before processing | |
| # 4. Minimal GITHUB_TOKEN permissions (permissions: {}) | |
| # 5. If secrets are compromised, rotate immediately: | |
| # - TOOLS_REPO_READER_PRIVATE_KEY | |
| # - REGISTRY_REDHAT_IO_RGY_PASSWORD | |
| # - REGISTRY_STAGE_REDHAT_IO_RGY_PASSWORD | |
| # - QUAY_IO_ACMD_RGY_PASSWORD | |
| # - SLACK_WEBHOOK_URL | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| branch: | |
| description: 'Branch to checkout (defaults to current branch)' | |
| required: false | |
| type: string | |
| dry-run: | |
| description: 'Run in dry-run mode (no actual mirroring)' | |
| required: false | |
| type: boolean | |
| default: false | |
| workflow_run: | |
| workflows: ["Gen Bundle Contents When Triggered"] | |
| types: | |
| - completed | |
| defaults: | |
| run: | |
| shell: bash | |
| # Restrict GITHUB_TOKEN permissions to minimum required | |
| # This prevents token misuse if the workflow is compromised | |
| permissions: {} | |
| jobs: | |
| mirror-images: | |
| runs-on: ubuntu-24.04 | |
| # Only run if the triggering workflow succeeded or if manually triggered | |
| if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} | |
| steps: | |
| - name: Validate workflow_run repository | |
| if: github.event_name == 'workflow_run' | |
| env: | |
| WORKFLOW_REPO: ${{ github.event.workflow_run.head_repository.full_name }} | |
| EXPECTED_REPO: ${{ github.repository }} | |
| run: | | |
| echo "Validating workflow_run trigger source..." | |
| echo "Expected repository: ${EXPECTED_REPO}" | |
| echo "Triggering repository: ${WORKFLOW_REPO}" | |
| if [ "${WORKFLOW_REPO}" != "${EXPECTED_REPO}" ]; then | |
| echo "ERROR: workflow_run triggered from untrusted repository!" | |
| echo "This workflow only accepts triggers from ${EXPECTED_REPO}" | |
| exit 1 | |
| fi | |
| echo "Repository validation passed." | |
| - name: Log workflow trigger context | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| WORKFLOW_NAME: ${{ github.event.workflow_run.name }} | |
| WORKFLOW_SHA: ${{ github.event.workflow_run.head_sha }} | |
| WORKFLOW_BRANCH: ${{ github.event.workflow_run.head_branch }} | |
| WORKFLOW_REPO: ${{ github.event.workflow_run.head_repository.full_name }} | |
| INPUT_BRANCH: ${{ inputs.branch }} | |
| run: | | |
| echo "Workflow triggered by: ${EVENT_NAME}" | |
| if [ "${EVENT_NAME}" = "workflow_run" ]; then | |
| echo "Triggering workflow: ${WORKFLOW_NAME}" | |
| echo "Triggering commit SHA: ${WORKFLOW_SHA}" | |
| echo "Triggering branch: ${WORKFLOW_BRANCH}" | |
| echo "Triggering repo: ${WORKFLOW_REPO}" | |
| elif [ "${EVENT_NAME}" = "workflow_dispatch" ]; then | |
| echo "Manual trigger - branch input: ${INPUT_BRANCH}" | |
| fi | |
| - name: Checkout repository | |
| uses: actions/checkout@v4 | |
| with: | |
| # When triggered by workflow_run, checkout the commit SHA that triggered it (persists even if branch is deleted) | |
| # When triggered manually, checkout the specified branch or current branch | |
| ref: ${{ inputs.branch || github.event.workflow_run.head_sha || github.ref }} | |
| path: workflow | |
| sparse-checkout: | | |
| config | |
| show-progress: false | |
| - name: Generate GitHub token to read release tools repo | |
| id: gen-tools-repo-token | |
| uses: actions/create-github-app-token@v1 | |
| with: | |
| app-id: ${{ vars.TOOLS_REPO_READER_APP_ID }} | |
| private-key: ${{ secrets.TOOLS_REPO_READER_PRIVATE_KEY }} | |
| owner: ${{ vars.TOOLS_REPO_OWNER }} | |
| repositories: "release" | |
| - name: Checkout release tools repo | |
| uses: actions/checkout@v4 | |
| with: | |
| repository: ${{ vars.TOOLS_REPO_OWNER }}/release | |
| ref: master | |
| token: ${{ steps.gen-tools-repo-token.outputs.token }} | |
| path: release-tools | |
| sparse-checkout: | | |
| tools/konflux/bundle/utils | |
| show-progress: false | |
| - name: Set up Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: '3.12' | |
| - name: Install skopeo | |
| run: | | |
| sudo apt-get update | |
| sudo apt-get install -y skopeo | |
| - name: Authenticate and run mirror script | |
| id: mirror | |
| continue-on-error: true | |
| run: | | |
| # Authenticate to needed image registries... | |
| # Access to registry.redhat.io needed to resolve external image references (shipped images): | |
| echo "Doing skopeo login to registry.redhat.io" | |
| echo "${{ secrets.REGISTRY_REDHAT_IO_RGY_PASSWORD }}" \ | |
| | skopeo login -u="${{ vars.REGISTRY_REDHAT_IO_RGY_USERNAME }}" --password-stdin registry.redhat.io | |
| # Access to registry.stage.redhat.io needed to resolve staging image references: | |
| echo "Doing skopeo login to registry.stage.redhat.io" | |
| echo "${{ secrets.REGISTRY_STAGE_REDHAT_IO_RGY_PASSWORD }}" \ | |
| | skopeo login -u="${{ vars.REGISTRY_STAGE_REDHAT_IO_RGY_USERNAME }}" --password-stdin registry.stage.redhat.io | |
| # Access to quay.io/acm-d needed to resolve external image references (in-dev images): | |
| echo "Doing skopeo login to quay.io (for acm-d)" | |
| echo "${{ secrets.QUAY_IO_ACMD_RGY_PASSWORD }}" \ | |
| | skopeo login -u="${{ vars.QUAY_IO_ACMD_RGY_USERNAME }}" --password-stdin quay.io | |
| # Run the mirror script | |
| ARGS="--operator mce" | |
| if [ "${{ inputs.dry-run }}" == "true" ]; then | |
| ARGS="$ARGS --dry-run" | |
| fi | |
| # Run the script from stolostron/release repo, using config from workflow (bundle) repo | |
| cd workflow | |
| python ../release-tools/tools/konflux/bundle/utils/mirror-external-images.py $ARGS | |
| - name: Send Slack notification | |
| if: always() | |
| env: | |
| EVENT_NAME: ${{ github.event_name }} | |
| REF_NAME: ${{ github.ref_name }} | |
| run: | | |
| # Read results from the JSON file | |
| if [ ! -f workflow/mirror-results.json ]; then | |
| echo "No results file found, creating minimal report" | |
| STATUS="❌ *FAILED*" | |
| SUMMARY="Mirror script did not complete successfully" | |
| DETAILS="" | |
| else | |
| # Parse the JSON results | |
| MIRRORED=$(jq -r '.mirrored' workflow/mirror-results.json) | |
| SKIPPED=$(jq -r '.skipped' workflow/mirror-results.json) | |
| FAILED=$(jq -r '.failed' workflow/mirror-results.json) | |
| DRY_RUN=$(jq -r '.dry_run' workflow/mirror-results.json) | |
| # Determine status | |
| if [ "$FAILED" -gt 0 ]; then | |
| STATUS="⚠️ *COMPLETED WITH FAILURES*" | |
| elif [ "$DRY_RUN" = "true" ]; then | |
| STATUS="🔍 *DRY RUN COMPLETED*" | |
| else | |
| STATUS="✅ *SUCCESS*" | |
| fi | |
| # Build summary | |
| SUMMARY="*Summary:*\n• Mirrored: $MIRRORED\n• Skipped: $SKIPPED\n• Failed: $FAILED" | |
| # Build failure details if any | |
| DETAILS="" | |
| if [ "$FAILED" -gt 0 ]; then | |
| DETAILS="\n\n*Failed Images:*\n" | |
| FAILURES=$(jq -r '.failures[] | "• `\(.image_key)` [\(.operator)]\n Source: `\(.source)`\n Target: `\(.target)`\n Error: \(.error)\n"' workflow/mirror-results.json) | |
| DETAILS="$DETAILS$FAILURES" | |
| fi | |
| fi | |
| # Build the Slack message | |
| WORKFLOW_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" | |
| MESSAGE=$(cat <<EOF | |
| { | |
| "blocks": [ | |
| { | |
| "type": "header", | |
| "text": { | |
| "type": "plain_text", | |
| "text": "Image Mirroring Report" | |
| } | |
| }, | |
| { | |
| "type": "section", | |
| "text": { | |
| "type": "mrkdwn", | |
| "text": "$STATUS\n\n$SUMMARY$DETAILS" | |
| } | |
| }, | |
| { | |
| "type": "section", | |
| "fields": [ | |
| { | |
| "type": "mrkdwn", | |
| "text": "*Triggered by:*\n${EVENT_NAME}" | |
| }, | |
| { | |
| "type": "mrkdwn", | |
| "text": "*Branch:*\n${REF_NAME}" | |
| } | |
| ] | |
| }, | |
| { | |
| "type": "actions", | |
| "elements": [ | |
| { | |
| "type": "button", | |
| "text": { | |
| "type": "plain_text", | |
| "text": "View Workflow Run" | |
| }, | |
| "url": "$WORKFLOW_URL" | |
| } | |
| ] | |
| } | |
| ] | |
| } | |
| EOF | |
| ) | |
| # Send to Slack | |
| curl -X POST -H 'Content-type: application/json' \ | |
| --data "$MESSAGE" \ | |
| "${{ secrets.SLACK_WEBHOOK_URL }}" | |
| - name: Fail workflow if mirroring failed | |
| if: steps.mirror.outcome == 'failure' | |
| run: exit 1 |