Preview Shop - start shop-preview-27495647570 caller=27495647570 #470
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
| # Dynamic mirrord Preview Environment: detects changed services, builds images, | |
| # and starts a preview for each. Supports two entry points: | |
| # - PR open/reopen for demo-* branches | |
| # - workflow_dispatch for automation such as preview-verification.yml | |
| # Uses metalbear-co/mirrord-preview GitHub Action. | |
| # Requires: GCP_WIF_PROVIDER + GCP_SERVICE_ACCOUNT + SLACK_WEBHOOK_URL secrets, | |
| # mirrord operator with Enterprise license. | |
| name: Preview Environment - Shop (Dynamic) | |
| run-name: >- | |
| Preview Shop - | |
| ${{ | |
| github.event_name == 'workflow_dispatch' && | |
| format('{0} {1} caller={2}', inputs.action, inputs.preview_key || inputs.branch || 'manual', inputs.caller_run_id || 'na') || | |
| format('{0} PR #{1}', github.event.action, github.event.pull_request.number) | |
| }} | |
| on: | |
| pull_request: | |
| types: [opened, reopened, closed] | |
| workflow_dispatch: | |
| inputs: | |
| action: | |
| description: start or stop previews | |
| required: true | |
| type: choice | |
| options: | |
| - start | |
| - stop | |
| branch: | |
| description: PR head branch to preview | |
| required: false | |
| type: string | |
| base_ref: | |
| description: PR base branch for changed-files diff | |
| required: false | |
| default: main | |
| type: string | |
| preview_key: | |
| description: mirrord preview key | |
| required: false | |
| type: string | |
| pr_number: | |
| description: PR number, when known | |
| required: false | |
| type: string | |
| pr_url: | |
| description: PR URL, when known | |
| required: false | |
| type: string | |
| caller_run_id: | |
| description: Parent workflow run id for correlation | |
| required: false | |
| type: string | |
| concurrency: | |
| group: preview-shop-${{ github.event.pull_request.number || github.event.inputs.pr_number || github.run_id }} | |
| cancel-in-progress: true | |
| jobs: | |
| resolve-context: | |
| name: Resolve trigger context | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| issues: read | |
| outputs: | |
| should_run: ${{ steps.resolve.outputs.should_run }} | |
| mode: ${{ steps.resolve.outputs.mode }} | |
| branch: ${{ steps.resolve.outputs.branch }} | |
| base_ref: ${{ steps.resolve.outputs.base_ref }} | |
| preview_key: ${{ steps.resolve.outputs.preview_key }} | |
| image_tag_key: ${{ steps.resolve.outputs.image_tag_key }} | |
| pr_number: ${{ steps.resolve.outputs.pr_number }} | |
| pr_url: ${{ steps.resolve.outputs.pr_url }} | |
| should_comment: ${{ steps.resolve.outputs.should_comment }} | |
| services_reason: ${{ steps.resolve.outputs.services_reason }} | |
| steps: | |
| - name: Resolve event into shared outputs | |
| id: resolve | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const sameRepoName = `${owner}/${repo}`; | |
| const trimmed = value => (value || '').trim(); | |
| const wantsPreview = body => trimmed(body) === '/preview'; | |
| const sanitizeTag = value => { | |
| const sanitized = (value || '') | |
| .toLowerCase() | |
| .replace(/[^a-z0-9._-]+/g, '-') | |
| .replace(/^-+|-+$/g, '') | |
| .slice(0, 60); | |
| return sanitized || 'preview'; | |
| }; | |
| const previewKeyFromBranch = value => { | |
| const branch = trimmed(value); | |
| const segments = branch.split('/').filter(Boolean); | |
| return segments[segments.length - 1] || branch; | |
| }; | |
| const out = { | |
| should_run: 'false', | |
| mode: '', | |
| branch: '', | |
| base_ref: 'main', | |
| preview_key: '', | |
| image_tag_key: 'preview', | |
| pr_number: '', | |
| pr_url: '', | |
| should_comment: 'false', | |
| services_reason: '', | |
| }; | |
| if (context.eventName === 'workflow_dispatch') { | |
| const inputs = context.payload.inputs || {}; | |
| out.mode = inputs.action || 'start'; | |
| out.branch = trimmed(inputs.branch); | |
| out.base_ref = trimmed(inputs.base_ref) || 'main'; | |
| out.preview_key = trimmed(inputs.preview_key) || previewKeyFromBranch(out.branch); | |
| out.pr_number = trimmed(inputs.pr_number); | |
| out.pr_url = trimmed(inputs.pr_url) || (out.pr_number ? `${context.serverUrl}/${owner}/${repo}/pull/${out.pr_number}` : ''); | |
| out.image_tag_key = sanitizeTag(out.branch || out.preview_key); | |
| out.should_comment = out.pr_number ? 'true' : 'false'; | |
| out.services_reason = `workflow_dispatch:${out.mode}`; | |
| out.should_run = out.mode === 'stop' | |
| ? (out.preview_key ? 'true' : 'false') | |
| : (out.branch ? 'true' : 'false'); | |
| } else if (context.eventName === 'pull_request') { | |
| const pr = context.payload.pull_request; | |
| const action = context.payload.action; | |
| const branch = pr.head.ref; | |
| const isDemoBranch = branch.startsWith('demo-'); | |
| const isSameRepo = pr.head.repo.full_name === sameRepoName; | |
| out.mode = action === 'closed' ? 'stop' : 'start'; | |
| out.branch = branch; | |
| out.base_ref = pr.base.ref; | |
| out.preview_key = previewKeyFromBranch(branch); | |
| out.pr_number = String(pr.number); | |
| out.pr_url = pr.html_url; | |
| out.image_tag_key = sanitizeTag(branch); | |
| out.should_comment = 'true'; | |
| if (action === 'closed') { | |
| let hasPreviewComment = false; | |
| if (!isDemoBranch) { | |
| const comments = await github.paginate(github.rest.issues.listComments, { | |
| owner, | |
| repo, | |
| issue_number: pr.number, | |
| per_page: 100, | |
| }); | |
| hasPreviewComment = comments.some(comment => wantsPreview(comment.body)); | |
| } | |
| out.should_run = isSameRepo && (isDemoBranch || hasPreviewComment) ? 'true' : 'false'; | |
| out.services_reason = isDemoBranch ? 'pull_request:closed-demo' : 'pull_request:closed-commented'; | |
| } else { | |
| out.should_run = isSameRepo && isDemoBranch ? 'true' : 'false'; | |
| out.services_reason = isDemoBranch ? 'pull_request:auto-demo' : 'pull_request:ignored'; | |
| } | |
| } else { | |
| out.services_reason = `unsupported:${context.eventName}`; | |
| } | |
| for (const [key, value] of Object.entries(out)) { | |
| core.setOutput(key, value); | |
| } | |
| detect-changes: | |
| name: Detect changed services | |
| needs: resolve-context | |
| if: needs.resolve-context.outputs.should_run == 'true' && needs.resolve-context.outputs.mode == 'start' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| matrix: ${{ steps.build-matrix.outputs.matrix }} | |
| has_changes: ${{ steps.build-matrix.outputs.has_changes }} | |
| services_list: ${{ steps.build-matrix.outputs.services_list }} | |
| head_sha: ${{ steps.build-matrix.outputs.head_sha }} | |
| steps: | |
| - name: Checkout PR branch | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| ref: ${{ needs.resolve-context.outputs.branch }} | |
| - name: Detect changed services via git diff | |
| id: build-matrix | |
| env: | |
| BASE_REF: ${{ needs.resolve-context.outputs.base_ref }} | |
| run: | | |
| set -euo pipefail | |
| git fetch origin "$BASE_REF" | |
| CHANGED_FILES=$(git diff --name-only "origin/${BASE_REF}...HEAD") | |
| CHANGES='[]' | |
| for path in $(jq -r '.services[].path' .github/preview-services.json); do | |
| name=$(jq -r --arg p "$path" '.services[] | select(.path == $p) | .name' .github/preview-services.json) | |
| if echo "$CHANGED_FILES" | grep -q "^${path}/"; then | |
| CHANGES=$(echo "$CHANGES" | jq -c --arg n "$name" '. + [$n]') | |
| fi | |
| done | |
| MATRIX=$(jq -c --argjson changes "$CHANGES" \ | |
| '{include: [.services[] | select(.name as $n | $changes | index($n))]}' \ | |
| .github/preview-services.json) | |
| SERVICES_LIST=$(jq -r --argjson changes "$CHANGES" \ | |
| '[.services[] | select(.name as $n | $changes | index($n)) | .name] | join(", ")' \ | |
| .github/preview-services.json) | |
| COUNT=$(echo "$CHANGES" | jq 'length') | |
| echo "head_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" | |
| echo "matrix=$MATRIX" >> "$GITHUB_OUTPUT" | |
| echo "services_list=$SERVICES_LIST" >> "$GITHUB_OUTPUT" | |
| if [ "$COUNT" -gt 0 ]; then | |
| echo "has_changes=true" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "has_changes=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| echo "Changed services ($COUNT): $SERVICES_LIST" | |
| build-and-preview: | |
| name: Build & Preview (${{ matrix.name }}) | |
| needs: [resolve-context, detect-changes] | |
| if: needs.detect-changes.outputs.has_changes == 'true' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| strategy: | |
| matrix: ${{ fromJSON(needs.detect-changes.outputs.matrix) }} | |
| fail-fast: false | |
| permissions: | |
| contents: read | |
| packages: write | |
| id-token: write | |
| steps: | |
| - name: Checkout PR branch | |
| uses: actions/checkout@v4 | |
| with: | |
| ref: ${{ needs.resolve-context.outputs.branch }} | |
| - name: Set up QEMU | |
| uses: docker/setup-qemu-action@v3 | |
| - name: Set up Docker Buildx | |
| uses: docker/setup-buildx-action@v3 | |
| - name: Login to GitHub Container Registry | |
| uses: docker/login-action@v3 | |
| with: | |
| registry: ghcr.io | |
| username: ${{ github.repository_owner }} | |
| password: ${{ secrets.GITHUB_TOKEN }} | |
| - name: Build and push image | |
| uses: docker/build-push-action@v5 | |
| with: | |
| context: ${{ matrix.path }} | |
| file: ${{ matrix.path }}/Dockerfile | |
| platforms: linux/amd64 | |
| push: true | |
| tags: ghcr.io/metalbear-co/playground-${{ matrix.name }}:preview-${{ needs.resolve-context.outputs.image_tag_key }}-${{ needs.detect-changes.outputs.head_sha }} | |
| build-args: ${{ matrix.build_args }} | |
| cache-from: type=gha,scope=${{ matrix.name }} | |
| cache-to: type=gha,scope=${{ matrix.name }},mode=max | |
| - name: Authenticate to GCP via Workload Identity Federation | |
| uses: google-github-actions/auth@v2 | |
| with: | |
| workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} | |
| service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} | |
| - name: Get GKE credentials | |
| uses: google-github-actions/get-gke-credentials@v2 | |
| with: | |
| cluster_name: playground-cluster-1 | |
| location: us-central1-c | |
| project_id: playground-383912 | |
| - name: Start mirrord preview | |
| uses: metalbear-co/mirrord-preview@master | |
| with: | |
| action: start | |
| target: deployment/${{ matrix.deployment }} | |
| namespace: ${{ matrix.namespace }} | |
| image: ghcr.io/metalbear-co/playground-${{ matrix.name }}:preview-${{ needs.resolve-context.outputs.image_tag_key }}-${{ needs.detect-changes.outputs.head_sha }} | |
| mode: steal | |
| filter: 'baggage:\s*[^\n]*\bmirrord-session={{ key }}\b' | |
| ttl_mins: '120' | |
| key: ${{ needs.resolve-context.outputs.preview_key }} | |
| extra_config: ${{ matrix.extra_config }} | |
| - name: Write result artifact | |
| if: success() | |
| run: | | |
| mkdir -p /tmp/preview-results | |
| echo '{"name":"${{ matrix.name }}"}' > /tmp/preview-results/${{ matrix.name }}.json | |
| - name: Upload result artifact | |
| if: success() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: preview-result-${{ matrix.name }} | |
| path: /tmp/preview-results/${{ matrix.name }}.json | |
| retention-days: 1 | |
| notify: | |
| name: Post notifications | |
| needs: [resolve-context, detect-changes, build-and-preview] | |
| if: needs.detect-changes.outputs.has_changes == 'true' && !cancelled() && needs.resolve-context.outputs.should_comment == 'true' | |
| runs-on: ubuntu-latest | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| steps: | |
| - name: Download all result artifacts | |
| uses: actions/download-artifact@v4 | |
| with: | |
| pattern: preview-result-* | |
| path: /tmp/preview-results | |
| merge-multiple: true | |
| - name: Post PR comment with preview details | |
| uses: actions/github-script@v7 | |
| env: | |
| PREVIEW_KEY: ${{ needs.resolve-context.outputs.preview_key }} | |
| PR_NUMBER: ${{ needs.resolve-context.outputs.pr_number }} | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const key = process.env.PREVIEW_KEY; | |
| const issueNumber = Number(process.env.PR_NUMBER); | |
| const url = 'https://playground.metalbear.dev/shop'; | |
| const dir = '/tmp/preview-results'; | |
| let services = []; | |
| try { | |
| const files = fs.readdirSync(dir).filter(f => f.endsWith('.json')); | |
| services = files.map(f => JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'))); | |
| } catch (e) { | |
| console.log('No preview results found, some or all previews may have failed.'); | |
| } | |
| const serviceList = services.length > 0 | |
| ? services.map(s => `| \`${s.name}\` | \`deployment/${s.name}\` |`).join('\n') | |
| : '| _(no previews succeeded)_ | |'; | |
| const body = [ | |
| '## mirrord Preview Environment - Metal Mart', | |
| '', | |
| 'Preview environments are running for this PR.', | |
| '', | |
| '| Service | Target |', | |
| '|---|---|', | |
| serviceList, | |
| '', | |
| '| | |', | |
| '|---|---|', | |
| `| **Preview URL** | [${url}](${url}) |`, | |
| `| **Header** | \`baggage: mirrord-session=${key}\` |`, | |
| '', | |
| 'To send traffic to this preview:', | |
| `- Use the [mirrord Browser Extension](https://metalbear.com/mirrord/docs/using-mirrord/browser-extension) and set the header for this URL, or`, | |
| `- \`curl -H "baggage: mirrord-session=${key}" ${url}\``, | |
| '', | |
| '*Preview is stopped when the PR is merged or closed.*', | |
| ].join('\n'); | |
| const { data: comments } = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| }); | |
| const botComment = comments.find(c => c.body && c.body.includes('## mirrord Preview Environment - Metal Mart')); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body, | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: issueNumber, | |
| body, | |
| }); | |
| } | |
| - name: Post preview link to Slack | |
| uses: slackapi/slack-github-action@v1.27.0 | |
| with: | |
| payload: | | |
| { | |
| "blocks": [ | |
| { | |
| "type": "header", | |
| "text": { "type": "plain_text", "text": "mirrord Preview Environment - Metal Mart" } | |
| }, | |
| { | |
| "type": "section", | |
| "fields": [ | |
| { "type": "mrkdwn", "text": "*PR:*\n<${{ needs.resolve-context.outputs.pr_url }}|#${{ needs.resolve-context.outputs.pr_number }}>" }, | |
| { "type": "mrkdwn", "text": "*Services:*\n${{ needs.detect-changes.outputs.services_list }}" }, | |
| { "type": "mrkdwn", "text": "*Preview URL:*\n<https://playground.metalbear.dev/shop|Open Preview>" }, | |
| { "type": "mrkdwn", "text": "*Header:*\n`baggage: mirrord-session=${{ needs.resolve-context.outputs.preview_key }}`" } | |
| ] | |
| } | |
| ] | |
| } | |
| env: | |
| SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} | |
| SLACK_WEBHOOK_TYPE: INCOMING_WEBHOOK | |
| preview-stop: | |
| name: Stop all previews | |
| needs: resolve-context | |
| if: needs.resolve-context.outputs.should_run == 'true' && needs.resolve-context.outputs.mode == 'stop' | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 5 | |
| permissions: | |
| contents: read | |
| id-token: write | |
| steps: | |
| - name: Authenticate to GCP via Workload Identity Federation | |
| uses: google-github-actions/auth@v2 | |
| with: | |
| workload_identity_provider: ${{ secrets.GCP_WIF_PROVIDER }} | |
| service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }} | |
| - name: Get GKE credentials | |
| uses: google-github-actions/get-gke-credentials@v2 | |
| with: | |
| cluster_name: playground-cluster-1 | |
| location: us-central1-c | |
| project_id: playground-383912 | |
| - name: Stop mirrord preview | |
| uses: metalbear-co/mirrord-preview@master | |
| with: | |
| action: stop | |
| key: ${{ needs.resolve-context.outputs.preview_key }} |