chore(deps): bump docker/login-action from 3 to 4 #29
Workflow file for this run
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
| name: PR Validation | |
| on: | |
| pull_request: | |
| branches: [master] | |
| types: [opened, synchronize, reopened] | |
| permissions: | |
| contents: write | |
| pull-requests: write | |
| jobs: | |
| validate: | |
| name: Validate Pull Request | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout PR code | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| cache: 'npm' | |
| - name: Install dependencies | |
| run: npm ci | |
| - name: Lint code | |
| run: npm run lint | |
| - name: Build project | |
| run: npm run build | |
| - name: Validate Dockerfile | |
| run: docker build -t diffractwd-com:pr-${{ github.event.pull_request.number }} . | |
| - name: Install Playwright browsers | |
| run: npx playwright install --with-deps chromium | |
| - name: Extract base branch screenshots | |
| run: | | |
| mkdir -p /tmp/screenshots-base | |
| BASE_SHA="${{ github.event.pull_request.base.sha }}" | |
| git show "${BASE_SHA}":e2e/screenshots/ 2>/dev/null | \ | |
| grep '\.png$' | while read f; do | |
| git show "${BASE_SHA}:e2e/screenshots/${f}" > "/tmp/screenshots-base/${f}" 2>/dev/null || true | |
| done | |
| echo "Base screenshots:" | |
| ls /tmp/screenshots-base/ 2>/dev/null || echo "None found" | |
| - name: Copy base screenshots as baselines | |
| run: | | |
| if ls /tmp/screenshots-base/*.png 1>/dev/null 2>&1; then | |
| cp /tmp/screenshots-base/*.png e2e/screenshots/ | |
| echo "Copied base branch screenshots as baselines" | |
| else | |
| echo "No base screenshots found — first run will generate baselines" | |
| fi | |
| - name: Run E2E tests (visual warnings only) | |
| id: e2e | |
| continue-on-error: true | |
| env: | |
| CI: true | |
| VISUAL_MODE: warning | |
| run: npx playwright test 2>&1 | tee e2e-output.txt | |
| - name: Collect current screenshots | |
| if: always() | |
| run: | | |
| mkdir -p /tmp/screenshots-current | |
| if [ -d "test-results" ]; then | |
| find test-results -name "*-actual.png" | while read f; do | |
| name=$(basename "$f" | sed 's/-actual\.png/.png/') | |
| cp "$f" "/tmp/screenshots-current/$name" | |
| done | |
| fi | |
| for f in e2e/screenshots/*.png; do | |
| name=$(basename "$f") | |
| if [ ! -f "/tmp/screenshots-current/$name" ]; then | |
| cp "$f" "/tmp/screenshots-current/$name" | |
| fi | |
| done | |
| echo "Current screenshots:" | |
| ls /tmp/screenshots-current/ | |
| - name: Run smart diff comparison | |
| if: always() | |
| run: | | |
| node scripts/compare-screenshots.js \ | |
| /tmp/screenshots-base \ | |
| /tmp/screenshots-current \ | |
| --output /tmp/comparison.json \ | |
| --diff-dir /tmp/screenshot-diffs \ | |
| --highlight-dir /tmp/screenshot-highlights | |
| - name: Ensure ci-assets release exists | |
| if: always() | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| gh release view ci-assets 2>/dev/null || \ | |
| gh release create ci-assets --title "CI Assets" --notes "Automated visual regression assets" --prerelease | |
| - name: Upload images to release assets | |
| if: always() | |
| env: | |
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| PR_NUM="${{ github.event.pull_request.number }}" | |
| RUN_ID="${{ github.run_id }}" | |
| REPO="${{ github.repository }}" | |
| PREFIX="pr${PR_NUM}-run${RUN_ID}" | |
| # Delete old assets for this PR | |
| gh release view ci-assets --json assets --jq ".assets[].name" 2>/dev/null | \ | |
| grep "^pr${PR_NUM}-" | while read -r old; do | |
| gh release delete-asset ci-assets "$old" --yes 2>/dev/null || true | |
| done | |
| # Read changed files | |
| CHANGED=$(node -e " | |
| const r = JSON.parse(require('fs').readFileSync('/tmp/comparison.json','utf-8')); | |
| r.images.filter(i => i.status === 'changed').forEach(i => console.log(i.name)); | |
| ") | |
| mkdir -p /tmp/upload-staging | |
| URLS="" | |
| if [ -n "$CHANGED" ]; then | |
| while IFS= read -r name; do | |
| [ -z "$name" ] && continue | |
| BEFORE_URL="" | |
| AFTER_URL="" | |
| HIGHLIGHT_URL="" | |
| # Upload before (copy with prefixed name, then upload) | |
| if [ -f "/tmp/screenshots-base/$name" ]; then | |
| ASSET="${PREFIX}-before-${name}" | |
| cp "/tmp/screenshots-base/$name" "/tmp/upload-staging/${ASSET}" | |
| gh release upload ci-assets "/tmp/upload-staging/${ASSET}" --clobber 2>/dev/null || true | |
| BEFORE_URL="https://github.com/${REPO}/releases/download/ci-assets/${ASSET}" | |
| fi | |
| # Upload after | |
| if [ -f "/tmp/screenshots-current/$name" ]; then | |
| ASSET="${PREFIX}-after-${name}" | |
| cp "/tmp/screenshots-current/$name" "/tmp/upload-staging/${ASSET}" | |
| gh release upload ci-assets "/tmp/upload-staging/${ASSET}" --clobber 2>/dev/null || true | |
| AFTER_URL="https://github.com/${REPO}/releases/download/ci-assets/${ASSET}" | |
| fi | |
| # Upload highlight (smart diff) | |
| if [ -f "/tmp/screenshot-highlights/highlight-$name" ]; then | |
| ASSET="${PREFIX}-highlight-${name}" | |
| cp "/tmp/screenshot-highlights/highlight-$name" "/tmp/upload-staging/${ASSET}" | |
| gh release upload ci-assets "/tmp/upload-staging/${ASSET}" --clobber 2>/dev/null || true | |
| HIGHLIGHT_URL="https://github.com/${REPO}/releases/download/ci-assets/${ASSET}" | |
| fi | |
| URLS="${URLS}${name}|${BEFORE_URL}|${AFTER_URL}|${HIGHLIGHT_URL}\n" | |
| done <<< "$CHANGED" | |
| fi | |
| rm -rf /tmp/upload-staging | |
| echo -e "$URLS" > /tmp/image-urls.txt | |
| echo "Uploaded assets:" | |
| cat /tmp/image-urls.txt | |
| # Verify asset names | |
| echo "=== Release assets ===" | |
| gh release view ci-assets --json assets --jq '.assets[].name' | grep "^pr${PR_NUM}-" | sort | |
| - name: Upload all screenshots (combined artifact) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: all-screenshots | |
| path: | | |
| /tmp/screenshots-base/ | |
| /tmp/screenshots-current/ | |
| /tmp/screenshot-diffs/ | |
| /tmp/screenshot-highlights/ | |
| /tmp/comparison.json | |
| retention-days: 14 | |
| if-no-files-found: ignore | |
| - name: Comment on PR with results | |
| if: always() | |
| uses: actions/github-script@v7 | |
| with: | |
| script: | | |
| const fs = require('fs'); | |
| const jobStatus = '${{ job.status }}'; | |
| const e2eStatus = '${{ steps.e2e.outcome }}'; | |
| const prNumber = ${{ github.event.pull_request.number }}; | |
| const runId = '${{ github.run_id }}'; | |
| const repo = '${{ github.repository }}'; | |
| const statusIcon = jobStatus === 'success' ? '✅' : '❌'; | |
| const e2eIcon = e2eStatus === 'success' ? '✅' : '⚠️'; | |
| // Read comparison report | |
| let report = { images: [], summary: { total: 0, changed: 0, added: 0, removed: 0, unchanged: 0 } }; | |
| try { | |
| report = JSON.parse(fs.readFileSync('/tmp/comparison.json', 'utf-8')); | |
| } catch {} | |
| // Read image URLs from release assets upload | |
| const urlMap = new Map(); | |
| try { | |
| const lines = fs.readFileSync('/tmp/image-urls.txt', 'utf-8').trim().split('\n'); | |
| for (const line of lines) { | |
| if (!line.trim()) continue; | |
| const [name, before, after, highlight] = line.split('|'); | |
| if (name) urlMap.set(name, { before, after, highlight }); | |
| } | |
| } catch {} | |
| const { summary } = report; | |
| const changedImages = report.images.filter(i => i.status === 'changed'); | |
| const addedImages = report.images.filter(i => i.status === 'added'); | |
| const removedImages = report.images.filter(i => i.status === 'removed'); | |
| // Checks table | |
| const checksTable = [ | |
| `| Step | Status |`, | |
| `|------|--------|`, | |
| `| 📦 Dependencies | ${statusIcon} |`, | |
| `| 🔍 Linting | ${statusIcon} |`, | |
| `| 🏗️ Build | ${statusIcon} |`, | |
| `| 🐳 Docker | ${statusIcon} |`, | |
| `| 🎭 E2E Tests | ${e2eIcon} |`, | |
| ].join('\n'); | |
| // Visual section with embedded images | |
| let visualSection = ''; | |
| if (changedImages.length > 0) { | |
| visualSection += `\n### 🔄 Visual Changes (${changedImages.length} of ${summary.total} screenshots)\n\n`; | |
| visualSection += `| 📊 | Count |\n|-----|-------|\n`; | |
| visualSection += `| 🔄 Changed | ${summary.changed} |\n`; | |
| if (summary.added > 0) visualSection += `| 🆕 Added | ${summary.added} |\n`; | |
| if (summary.removed > 0) visualSection += `| 🗑️ Removed | ${summary.removed} |\n`; | |
| visualSection += `| ✅ Unchanged | ${summary.unchanged} |\n\n`; | |
| for (const img of changedImages) { | |
| const pageName = img.name.replace('.png', '').replace(/-/g, ' '); | |
| const pct = img.changedPercent !== undefined ? ` — ${img.changedPercent}% changed` : ''; | |
| const pixels = img.changedPixels !== undefined ? ` (${img.changedPixels.toLocaleString()} px)` : ''; | |
| const urls = urlMap.get(img.name); | |
| visualSection += `<details>\n`; | |
| visualSection += `<summary>📸 <strong>${pageName}</strong>${pct}${pixels}</summary>\n\n`; | |
| if (urls?.before && urls?.after && urls?.highlight) { | |
| visualSection += `| 🔙 Before | 🔜 After | 🔬 Smart Diff |\n`; | |
| visualSection += `|-----------|----------|---------------|\n`; | |
| visualSection += `| <img src="${urls.before}" width="280" /> | <img src="${urls.after}" width="280" /> | <img src="${urls.highlight}" width="280" /> |\n\n`; | |
| } else if (urls?.before && urls?.after) { | |
| visualSection += `| 🔙 Before | 🔜 After |\n`; | |
| visualSection += `|-----------|----------|\n`; | |
| visualSection += `| <img src="${urls.before}" width="400" /> | <img src="${urls.after}" width="400" /> |\n\n`; | |
| } else { | |
| visualSection += `> ⬇️ Download **all-screenshots** artifact to review.\n\n`; | |
| } | |
| visualSection += `</details>\n\n`; | |
| } | |
| if (addedImages.length > 0) { | |
| visualSection += `#### 🆕 Added Screenshots\n`; | |
| for (const img of addedImages) { | |
| visualSection += `- \`${img.name}\`\n`; | |
| } | |
| visualSection += '\n'; | |
| } | |
| if (removedImages.length > 0) { | |
| visualSection += `#### 🗑️ Removed Screenshots\n`; | |
| for (const img of removedImages) { | |
| visualSection += `- \`${img.name}\`\n`; | |
| } | |
| visualSection += '\n'; | |
| } | |
| } else { | |
| visualSection += `\n### ✅ Visual Regression\n\n`; | |
| visualSection += `All ${summary.total} screenshots match baselines — no visual changes detected.\n\n`; | |
| } | |
| // Page status table | |
| const pages = ['home', 'screenshots', 'documentation', 'downloads', 'support']; | |
| const getIcon = (name) => { | |
| const img = report.images.find(i => i.name === name); | |
| return !img ? '➖' : img.status === 'changed' ? '🔄' : '✅'; | |
| }; | |
| const pageRows = pages.map(p => { | |
| return `| ${p} | ${getIcon(`${p}-dark-desktop.png`)} | ${getIcon(`${p}-dark-mobile.png`)} | ${getIcon(`${p}-light-desktop.png`)} | ${getIcon(`${p}-light-mobile.png`)} |`; | |
| }).join('\n'); | |
| const mergeStatus = jobStatus === 'success' && e2eStatus === 'success' | |
| ? '🚀 **Ready to merge!**' | |
| : '⚠️ **Review changes before merging.**'; | |
| const body = [ | |
| `## 🔍 PR Validation ${statusIcon}`, | |
| ``, | |
| `> **Commit:** \`${{ github.sha }}\` | **Branch:** \`${{ github.head_ref }}\``, | |
| ``, | |
| `### 📋 Checks`, | |
| checksTable, | |
| ``, | |
| visualSection, | |
| `### 📸 Page Status`, | |
| `| Page | 🌙 Dark Desktop | 🌙 Dark Mobile | ☀️ Light Desktop | ☀️ Light Mobile |`, | |
| `|------|----------------|----------------|-----------------|-----------------|`, | |
| pageRows, | |
| ``, | |
| mergeStatus, | |
| ``, | |
| `---`, | |
| `🔗 [Workflow run](https://github.com/${repo}/actions/runs/${runId}) · ⬇️ [Download all screenshots](https://github.com/${repo}/actions/runs/${runId}#artifacts)`, | |
| `⏰ \`${new Date().toISOString()}\``, | |
| ].join('\n'); | |
| const comments = await github.rest.issues.listComments({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| }); | |
| const botComment = comments.data.find(comment => | |
| comment.user.login === 'github-actions[bot]' && | |
| comment.body.includes('PR Validation') | |
| ); | |
| if (botComment) { | |
| await github.rest.issues.updateComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| comment_id: botComment.id, | |
| body: body | |
| }); | |
| } else { | |
| await github.rest.issues.createComment({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: context.issue.number, | |
| body: body | |
| }); | |
| } |