Skip to content

Preview Shop - closed PR #338 #479

Preview Shop - closed PR #338

Preview Shop - closed PR #338 #479

Workflow file for this run

# 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 }}