Files
2026-07-13 12:58:18 +08:00

281 lines
12 KiB
YAML

name: "Showcase: Capture Previews"
on:
workflow_run:
workflows: ["Showcase: Build & Push"]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
slug:
description: "Integration slug to capture (leave empty for all)"
type: string
default: ""
demo:
description: "Demo ID to capture (leave empty for all)"
type: string
default: ""
push:
branches: [main]
paths:
- "showcase/integrations/*/src/app/demos/**"
- "showcase/integrations/*/manifest.yaml"
concurrency:
group: showcase-capture-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
jobs:
capture:
name: Capture Preview MP4s
# Hoist the Slack webhook into an env var so step-level `if:`
# expressions can reference it — `secrets.*` is not a valid
# named-value inside `if:` and causes a workflow startup failure.
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
runs-on: ubuntu-latest
# Captures demo previews as MP4 and uploads them to a GitHub release.
# Loop prevention: capture commits registry.json with the message below,
# which could re-trigger via push or the completed-deploy workflow_run.
# Skip when the triggering commit came from this workflow itself.
if: >-
(github.event_name != 'workflow_run' ||
!startsWith(github.event.workflow_run.head_commit.message, 'Update preview URLs in registry')) &&
(github.event_name != 'push' ||
!startsWith(github.event.head_commit.message, 'Update preview URLs in registry'))
timeout-minutes: 30
steps:
- name: Mint devops-bot token
# PROTECT_OUR_MAIN requires a PR for pushes to main; the default
# GITHUB_TOKEN fails with GH013 on the `Commit registry updates`
# step below. devops-bot (app-id 1108748) is a configured bypass
# actor on that ruleset — mint its installation token here and use
# it for checkout + push. Mirrors the pattern in
# CopilotKit/internal-skills's sync-versions.yml.
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
with:
app-id: "1108748"
private-key: ${{ secrets.DEVOPS_BOT_PRIVATE_KEY }}
permission-contents: write
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
lfs: true
ref: ${{ github.event.workflow_run.head_branch || github.ref }}
token: ${{ steps.app-token.outputs.token }}
persist-credentials: false
- name: Ensure preview release exists
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release view showcase-previews --repo ${{ github.repository }} >/dev/null 2>&1 || \
gh release create showcase-previews --repo ${{ github.repository }} \
--title "Showcase Preview Videos" \
--notes "Auto-generated demo preview videos for the showcase platform. Updated by CI." \
--latest=false
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install -y ffmpeg
- name: Install Playwright
run: npx playwright install chromium --with-deps
- name: Install script dependencies
working-directory: showcase/scripts
run: pnpm install --ignore-scripts || true
- name: Detect changed packages
id: detect
if: github.event_name == 'push'
run: |
# Find which package slugs had demo changes
CHANGED=$(git diff --name-only HEAD~1 HEAD -- 'showcase/integrations/*/src/app/demos/' 'showcase/integrations/*/manifest.yaml' | \
sed -n 's|showcase/integrations/\([^/]*\)/.*|\1|p' | sort -u | tr '\n' ',' | sed 's/,$//')
echo "slugs=$CHANGED" >> $GITHUB_OUTPUT
echo "Changed packages: $CHANGED"
- name: Generate registry
run: |
cd showcase/scripts
npm ci --silent
npx tsx generate-registry.ts
- name: Capture previews
env:
INPUT_SLUG: ${{ inputs.slug }}
INPUT_DEMO: ${{ inputs.demo }}
EVENT_NAME: ${{ github.event_name }}
DETECTED_SLUGS: ${{ steps.detect.outputs.slugs }}
run: |
# Build the args as an array (not a space-joined string) so a
# slug or demo value containing whitespace or shell metacharacters
# stays a single argument rather than being re-tokenized by the shell.
ARGS=()
# Use input slug/demo if provided (manual dispatch)
if [ -n "$INPUT_SLUG" ]; then
ARGS+=(--slug "$INPUT_SLUG")
fi
if [ -n "$INPUT_DEMO" ]; then
ARGS+=(--demo "$INPUT_DEMO")
fi
# Use detected slugs for push events (capture only changed)
if [ "$EVENT_NAME" = "push" ] && [ -n "$DETECTED_SLUGS" ]; then
# Capture each changed slug
IFS=',' read -ra SLUGS <<< "$DETECTED_SLUGS"
for slug in "${SLUGS[@]}"; do
npx tsx showcase/scripts/capture-previews.ts --slug "$slug" || true
done
else
npx tsx showcase/scripts/capture-previews.ts "${ARGS[@]}" || true
fi
- name: Configure git for push
run: |
git config user.name "devops-bot[bot]"
git config user.email "1108748+devops-bot[bot]@users.noreply.github.com"
git config --local url."https://x-access-token:${TOKEN}@github.com/".insteadOf "https://github.com/"
env:
TOKEN: ${{ steps.app-token.outputs.token }}
- name: Commit registry updates
run: |
cd showcase/shell/src/data
if git diff --quiet registry.json; then
echo "No registry changes"
exit 0
fi
git add -f registry.json
git commit -m "Update preview URLs in registry"
# Rebase-and-retry loop: the capture job can take ~30 minutes,
# during which other commits routinely land on main. Without
# this loop, `git push` loses the race and fails with "fetch
# first" (observed in runs 24799601181 and 24809331709 on
# 2026-04-22). Rebase our single registry commit onto the
# latest main and retry; bounded attempts so a persistent
# failure still surfaces rather than looping forever.
attempts=0
max_attempts=5
until git push; do
attempts=$((attempts + 1))
if [ "$attempts" -ge "$max_attempts" ]; then
echo "::error::push failed after $max_attempts rebase attempts"
exit 1
fi
echo "push rejected — rebasing onto latest main (attempt $attempts/$max_attempts)"
git pull --rebase origin "$(git rev-parse --abbrev-ref HEAD)"
done
# Slack failure alert. This workflow only runs on main-branch pushes,
# workflow_run completions, and manual dispatch — all of which
# constitute "production" events where silent failures (e.g. the
# GH013 PROTECT_OUR_MAIN regression that motivated PR #4159) must
# surface in #oss-alerts. Extracts failed step name + first
# meaningful error line so the payload is triage-ready rather than
# forcing a click-through, per the oss-alerts detail policy.
#
# Must NEVER fail the job (runs on failure() already; a crash here
# would compound the original failure and could block the notify
# step). All extraction uses best-effort fallbacks so a malformed
# jobs response or truncated log still yields sane defaults.
- name: Extract failure details for Slack
id: extract
if: failure() && env.SLACK_WEBHOOK != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
RUN_ID: ${{ github.run_id }}
run: |
set +e # best-effort: never block the notify step below
# --- Find the currently-running job and its first failed step ---
# The jobs API returns every job in the run. Match by job name
# first (mirrors `jobs.capture.name`); fall back to first job
# with a failed step if name match misses (e.g. future rename).
jobs_json=$(gh api "/repos/${GH_REPO}/actions/runs/${RUN_ID}/jobs" --paginate 2>/dev/null)
job_id=$(printf '%s' "$jobs_json" | jq -r '
.jobs // []
| map(select(.name == "Capture Preview MP4s"))
| (.[0].id // empty)
' 2>/dev/null)
if [ -z "$job_id" ]; then
job_id=$(printf '%s' "$jobs_json" | jq -r '
.jobs // []
| map(select(.steps // [] | map(.conclusion) | index("failure")))
| (.[0].id // empty)
' 2>/dev/null)
fi
failed_step=$(printf '%s' "$jobs_json" | jq -r --arg id "$job_id" '
.jobs // []
| map(select((.id|tostring) == $id))
| (.[0].steps // [])
| map(select(.conclusion == "failure"))
| (.[0].name // "unknown step")
' 2>/dev/null)
[ -z "$failed_step" ] && failed_step="unknown step"
# --- Pull log and extract first meaningful error line ------------
# `gh run view --log-failed` output is TSV: job\tstep\ttimestamp +
# content. Strip the three leading columns, strip ANSI escape
# codes + BOM, skip runner/group/env header noise, grab first
# line matching a recognised error marker. Truncate to ~300
# chars so the Slack payload stays under the 800-char budget.
error_excerpt="see workflow run for details"
if [ -n "$job_id" ]; then
log_excerpt=$(gh run view "$RUN_ID" --repo "$GH_REPO" --log-failed --job="$job_id" 2>/dev/null \
| awk -F'\t' 'NF>=3 { sub(/^[\xEF\xBB\xBF]?[0-9T:.\-Z ]+/, "", $3); print $3 }' \
| sed 's/\x1b\[[0-9;]*[a-zA-Z]//g' \
| grep -vE '^(##\[|shell: |env: |Run |[[:space:]]*$)' \
| grep -m1 -E '^\[(FAIL|ERROR)\]|^Error:|^error:|^::error' \
| head -c 300)
if [ -n "$log_excerpt" ]; then
error_excerpt="$log_excerpt"
fi
fi
# --- Emit to $GITHUB_ENV using heredoc delimiter -----------------
# Heredoc delimiter protects against values with `=` or newlines
# breaking the KEY=VALUE format.
{
echo "failed_step<<EOF_FAILED_STEP_b3f2"
printf '%s\n' "$failed_step"
echo "EOF_FAILED_STEP_b3f2"
echo "error_excerpt<<EOF_ERROR_EXCERPT_b3f2"
printf '%s\n' "$error_excerpt"
echo "EOF_ERROR_EXCERPT_b3f2"
} >> "$GITHUB_ENV"
exit 0 # belt-and-suspenders: never propagate a failure
- name: Notify Slack (failure)
if: failure() && env.SLACK_WEBHOOK != ''
uses: slackapi/slack-github-action@0d95c9a7becc1e6e297d76df9bc735c44f4cbcbc # v3.0.5
with:
webhook: ${{ secrets.SLACK_WEBHOOK_OSS_ALERTS }}
webhook-type: incoming-webhook
# Defensive: wrap dynamic values via toJSON(format(...)) so that
# if github.repository or extracted failed_step / error_excerpt
# contain characters that would break the JSON payload (quotes,
# backslashes, newlines), the value is safely JSON-encoded.
# Matches the pattern used in showcase_validate.yml.
payload: |
{ "text": ${{ toJSON(format(':x: *Showcase: Capture Previews*: failed — {0}: {1} | <https://github.com/{2}/actions/runs/{3}|View run>', env.failed_step, env.error_excerpt, github.repository, github.run_id)) }} }
- name: Log (no Slack — webhook unset)
if: failure() && env.SLACK_WEBHOOK == ''
run: |
echo "::warning::showcase_capture-previews failed but SLACK_WEBHOOK_OSS_ALERTS is not set; no Slack notification sent."