From 6f5a69533569245ac947aadd532487c1f823163a Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Tue, 19 May 2026 10:46:21 -0700 Subject: [PATCH 1/3] chore: add screenshot pipeline workflow for manual dispatch Adds the GitHub Actions workflow file so it can be triggered via workflow_dispatch. The pipeline scripts and configuration live on the feature/playwright-screenshot-pipeline branch. --- .github/workflows/screenshot-pipeline.yml | 132 ++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .github/workflows/screenshot-pipeline.yml diff --git a/.github/workflows/screenshot-pipeline.yml b/.github/workflows/screenshot-pipeline.yml new file mode 100644 index 0000000000000..3b82950374288 --- /dev/null +++ b/.github/workflows/screenshot-pipeline.yml @@ -0,0 +1,132 @@ +name: Screenshot Pipeline + +# Automated screenshot capture, diff, and replacement pipeline. +# Detects stale screenshots and Arcade embeds, auto-replaces high-confidence +# diffs, and creates Linear issues for items requiring manual review. + +on: + schedule: + - cron: '0 6 * * 1' # Weekly on Monday at 6am UTC + workflow_dispatch: # Manual trigger + inputs: + scope: + description: 'Limit crawl to a specific directory (e.g., docs/product/insights)' + required: false + type: string + dry_run: + description: 'Dry run mode (no file changes, no PRs, no Linear issues)' + required: false + type: boolean + default: false + diff_threshold_low: + description: 'Min diff % to consider changed (default: 0.01 = 1%)' + required: false + type: string + default: '0.01' + diff_threshold_high: + description: 'Max diff % before flagging as suspicious (default: 0.50 = 50%)' + required: false + type: string + default: '0.50' + +jobs: + screenshot-pipeline: + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for git log queries + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install pipeline dependencies + run: npm install + working-directory: scripts/screenshot-pipeline + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + working-directory: scripts/screenshot-pipeline + + - name: Restore auth state + if: ${{ !inputs.dry_run }} + run: | + echo "$SENTRY_STORAGE_STATE" | base64 -d > /tmp/storageState.json + env: + SENTRY_STORAGE_STATE: ${{ secrets.SENTRY_STORAGE_STATE }} + + - name: Run inventory crawler + run: | + SCOPE_ARG="" + if [ -n "${{ inputs.scope }}" ]; then + SCOPE_ARG="--scope ${{ inputs.scope }}" + fi + npx ts-node scripts/screenshot-pipeline/src/crawl-inventory.ts $SCOPE_ARG + env: + UI_REFRESH_CUTOFF: '2025-06-01' + + - name: Run screenshot capture & diff + run: | + npx ts-node scripts/screenshot-pipeline/src/capture-and-diff.ts \ + ${{ inputs.dry_run && '--dry-run' || '' }} + env: + SENTRY_STORAGE_STATE_PATH: /tmp/storageState.json + SENTRY_ORG_SLUG: ${{ secrets.SENTRY_ORG_SLUG }} + SENTRY_BASE_URL: ${{ secrets.SENTRY_BASE_URL }} + DIFF_THRESHOLD_LOW: ${{ inputs.diff_threshold_low || '0.01' }} + DIFF_THRESHOLD_HIGH: ${{ inputs.diff_threshold_high || '0.50' }} + + - name: Auto-replace and open PR + if: ${{ !inputs.dry_run }} + run: npx ts-node scripts/screenshot-pipeline/src/auto-replace.ts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Create Linear issues + if: ${{ !inputs.dry_run }} + run: npx ts-node scripts/screenshot-pipeline/src/create-linear-issues.ts + env: + LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} + LINEAR_TEAM_ID: ${{ vars.LINEAR_TEAM_ID }} + SENTRY_BASE_URL: ${{ secrets.SENTRY_BASE_URL }} + + - name: Upload pipeline artifacts + uses: actions/upload-artifact@v4 + if: always() + with: + name: screenshot-pipeline-output + path: scripts/screenshot-pipeline/output/ + retention-days: 30 + + - name: Write job summary + if: always() + run: | + echo "## Screenshot Pipeline Results" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + if [ -f scripts/screenshot-pipeline/output/inventory-manifest.json ]; then + TOTAL=$(jq length scripts/screenshot-pipeline/output/inventory-manifest.json) + STALE=$(jq '[.[] | select(.is_stale == true)] | length' scripts/screenshot-pipeline/output/inventory-manifest.json) + echo "**Inventory:** ${TOTAL} total assets, ${STALE} stale" >> $GITHUB_STEP_SUMMARY + fi + + if [ -f scripts/screenshot-pipeline/output/diff-results.json ]; then + CAPTURED=$(jq length scripts/screenshot-pipeline/output/diff-results.json) + AUTO=$(jq '[.[] | select(.status == "auto_replace")] | length' scripts/screenshot-pipeline/output/diff-results.json) + REVIEW=$(jq '[.[] | select(.status == "needs_review")] | length' scripts/screenshot-pipeline/output/diff-results.json) + FAILED=$(jq '[.[] | select(.status == "capture_failed")] | length' scripts/screenshot-pipeline/output/diff-results.json) + echo "**Captures:** ${CAPTURED} processed" >> $GITHUB_STEP_SUMMARY + echo "- Auto-replace: ${AUTO}" >> $GITHUB_STEP_SUMMARY + echo "- Needs review: ${REVIEW}" >> $GITHUB_STEP_SUMMARY + echo "- Capture failed: ${FAILED}" >> $GITHUB_STEP_SUMMARY + fi + + if [ -f scripts/screenshot-pipeline/output/linear-issues.json ]; then + CREATED=$(jq '[.[] | select(.isNew == true)] | length' scripts/screenshot-pipeline/output/linear-issues.json) + echo "**Linear issues created:** ${CREATED}" >> $GITHUB_STEP_SUMMARY + fi From 071f31b0fcb14e49ea9493f82b195dfc77654666 Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Tue, 19 May 2026 10:55:36 -0700 Subject: [PATCH 2/3] fix: address security and path issues in screenshot pipeline workflow - Fix shell injection: pass inputs.scope via env var instead of direct interpolation, with regex validation for safe directory paths - Fix shell injection: pass inputs.dry_run via env var - Add working-directory to all npx ts-node steps so they find deps - Use bash arrays for safe argument construction --- .github/workflows/screenshot-pipeline.yml | 38 +++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/.github/workflows/screenshot-pipeline.yml b/.github/workflows/screenshot-pipeline.yml index 3b82950374288..ba2fb473ba621 100644 --- a/.github/workflows/screenshot-pipeline.yml +++ b/.github/workflows/screenshot-pipeline.yml @@ -10,7 +10,7 @@ on: workflow_dispatch: # Manual trigger inputs: scope: - description: 'Limit crawl to a specific directory (e.g., docs/product/insights)' + description: 'Limit crawl to a specific directory (e.g., docs/product/issues)' required: false type: string dry_run: @@ -60,36 +60,56 @@ jobs: env: SENTRY_STORAGE_STATE: ${{ secrets.SENTRY_STORAGE_STATE }} + - name: Validate scope input + if: ${{ inputs.scope != '' }} + run: | + # Validate scope is a safe directory path (alphanumeric, hyphens, slashes, dots only) + if ! echo "$PIPELINE_SCOPE" | grep -qE '^[a-zA-Z0-9/_.-]+$'; then + echo "Error: Invalid scope input. Must be a directory path (e.g., docs/product/issues)" + exit 1 + fi + env: + PIPELINE_SCOPE: ${{ inputs.scope }} + - name: Run inventory crawler run: | - SCOPE_ARG="" - if [ -n "${{ inputs.scope }}" ]; then - SCOPE_ARG="--scope ${{ inputs.scope }}" + SCOPE_ARGS=() + if [ -n "$PIPELINE_SCOPE" ]; then + SCOPE_ARGS=(--scope "$PIPELINE_SCOPE") fi - npx ts-node scripts/screenshot-pipeline/src/crawl-inventory.ts $SCOPE_ARG + npx ts-node src/crawl-inventory.ts "${SCOPE_ARGS[@]}" + working-directory: scripts/screenshot-pipeline env: UI_REFRESH_CUTOFF: '2025-06-01' + PIPELINE_SCOPE: ${{ inputs.scope }} - name: Run screenshot capture & diff run: | - npx ts-node scripts/screenshot-pipeline/src/capture-and-diff.ts \ - ${{ inputs.dry_run && '--dry-run' || '' }} + DRY_RUN_ARGS=() + if [ "$PIPELINE_DRY_RUN" = "true" ]; then + DRY_RUN_ARGS=(--dry-run) + fi + npx ts-node src/capture-and-diff.ts "${DRY_RUN_ARGS[@]}" + working-directory: scripts/screenshot-pipeline env: SENTRY_STORAGE_STATE_PATH: /tmp/storageState.json SENTRY_ORG_SLUG: ${{ secrets.SENTRY_ORG_SLUG }} SENTRY_BASE_URL: ${{ secrets.SENTRY_BASE_URL }} DIFF_THRESHOLD_LOW: ${{ inputs.diff_threshold_low || '0.01' }} DIFF_THRESHOLD_HIGH: ${{ inputs.diff_threshold_high || '0.50' }} + PIPELINE_DRY_RUN: ${{ inputs.dry_run }} - name: Auto-replace and open PR if: ${{ !inputs.dry_run }} - run: npx ts-node scripts/screenshot-pipeline/src/auto-replace.ts + run: npx ts-node src/auto-replace.ts + working-directory: scripts/screenshot-pipeline env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Create Linear issues if: ${{ !inputs.dry_run }} - run: npx ts-node scripts/screenshot-pipeline/src/create-linear-issues.ts + run: npx ts-node src/create-linear-issues.ts + working-directory: scripts/screenshot-pipeline env: LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }} LINEAR_TEAM_ID: ${{ vars.LINEAR_TEAM_ID }} From e5563447ed858690a56006ec605aeb3ee609f22a Mon Sep 17 00:00:00 2001 From: Shannon Anahata Date: Tue, 19 May 2026 11:19:33 -0700 Subject: [PATCH 3/3] fix: skip auth state path in dry-run mode to prevent ENOENT --- .github/workflows/screenshot-pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/screenshot-pipeline.yml b/.github/workflows/screenshot-pipeline.yml index ba2fb473ba621..40a25ca73ac48 100644 --- a/.github/workflows/screenshot-pipeline.yml +++ b/.github/workflows/screenshot-pipeline.yml @@ -92,7 +92,7 @@ jobs: npx ts-node src/capture-and-diff.ts "${DRY_RUN_ARGS[@]}" working-directory: scripts/screenshot-pipeline env: - SENTRY_STORAGE_STATE_PATH: /tmp/storageState.json + SENTRY_STORAGE_STATE_PATH: ${{ inputs.dry_run && '' || '/tmp/storageState.json' }} SENTRY_ORG_SLUG: ${{ secrets.SENTRY_ORG_SLUG }} SENTRY_BASE_URL: ${{ secrets.SENTRY_BASE_URL }} DIFF_THRESHOLD_LOW: ${{ inputs.diff_threshold_low || '0.01' }}