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<> "$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} | ', 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."