name: UI Snapshot Update # Label-driven baseline update for the visual-snapshot suite # (tests/e2e_ui/visual/test_*_snapshot.py). # # Add the `update-ui-snapshot` label to a PR and this regenerates only the # baselines that DON'T match (or are missing) in the SAME digest-pinned Playwright # image the compare gate (ui-snapshot.yml) renders in, then commits the changed # PNGs back to the PR branch. Baselines that already pass are left byte-for-byte # untouched, so labeling to fix one page never churns the others. Replaces the # admin-only workflow_dispatch + manual download-and-commit dance. # # Two-job split (token isolation): the `render` job runs PR-controlled code (the # npm build + the test) in the container with NO push token anywhere on the # runner, and uploads only the rendered PNG as an artifact. The `commit` job # runs on a clean runner, executes NO PR code (it just checks out the branch, # drops in the PNG, and pushes), and is the only place the App token exists -- # so PR-controlled code can never tamper with the binaries/PATH the privileged # push later uses. # # Re-trigger: the push uses the OMNIGENT_BOT_APP token (NOT GITHUB_TOKEN, whose # pushes GitHub suppresses to avoid loops), so it re-fires the PR's full check # suite on the new commit -- no manual "Re-run". Falls back to GITHUB_TOKEN if # the App isn't configured (lands, but a maintainer must push to re-run CI), # mirroring oss-regen-on-comment.yml. # # Same-repo branches only: Actions tokens can't push to a fork branch, so fork # PRs are skipped here and update the baseline locally instead (Docker regen or # the artifact-adopt script -- see tests/e2e_ui/visual/README.md). on: pull_request: types: [labeled] # Read-only at the top level; the commit job widens its own scopes below. permissions: contents: read concurrency: group: ui-snapshot-update-${{ github.event.pull_request.number }} cancel-in-progress: true jobs: # 1) Render in the pinned image with NO token on the runner. PR-controlled # code runs only here; its sole output is the PNG artifact. render: name: Regenerate visual baselines (no token) permissions: contents: read # Same-repo only: a fork's read-only token can't push to the fork branch. if: >- github.event.label.name == 'update-ui-snapshot' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-24.04 # Same digest-pinned image as the compare gate, so the regenerated baseline # is byte-identical to what ui-snapshot.yml will then compare against. Keep # this digest in lockstep with ui-snapshot.yml and regen_baseline_docker.sh. container: image: mcr.microsoft.com/playwright/python:v1.60.0-noble@sha256:8ff591d613b01c884cc488339ed4318b4513eaf0c57a164a878ba49e70e3f384 # GitHub defaults `run:` steps inside a container to `sh` (dash); force bash # so the snapshot step's arrays / [[ ]] work (bash ships in the image). defaults: run: shell: bash timeout-minutes: 20 env: OMNIGENT_SKIP_WEB_UI: "true" # The pinned image runs as root, where Chromium needs --no-sandbox; the # e2e_ui conftest adds it (+ --disable-dev-shm-usage) when this is set. OMNIGENT_PW_NO_SANDBOX: "1" # Use the image's Python 3.12 (matches .python-version) rather than a uv # download -- no setup-python step needed inside the container. UV_PYTHON_PREFERENCE: only-system steps: - name: Checkout PR branch uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # No persisted credentials anywhere in this job: it runs PR-chosen code # and must never have a push token on disk. ref: ${{ github.event.pull_request.head.ref }} persist-credentials: false - name: Set up Node 20 uses: ./.github/actions/setup-node - name: Install uv uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 with: enable-cache: true - name: Cache virtualenv uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4 with: path: .venv # Namespaced + container-scoped to match ui-snapshot.yml (built with # the container's system Python, not the host interpreter). key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }} - name: Install project + dev extras run: uv sync --extra all --extra dev # No "playwright install": the pinned image ships matching Chromium + deps # under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright). - name: Build web SPA env: NPM_CONFIG_REGISTRY: https://registry.npmjs.org/ run: | cd web npm ci --legacy-peer-deps --no-audit --no-fund npm run build - name: Compare the baselines (no --update-snapshots) # Deliberately NOT --update-snapshots: that rewrites EVERY PNG, churning # baselines that already pass (a sub-threshold re-render still changes the # bytes). In plain compare mode the plugin leaves passing baselines # untouched and rewrites only the drift: under GitHub Actions it updates a # mismatching baseline IN PLACE (and creates a MISSING one) under # snapshots/, so the tree below already holds exactly the changed PNGs. # The run "fails" by design on any drift, so don't gate on its exit code. run: | uv run pytest tests/e2e_ui/visual -m visual \ -v --tb=long --log-level=INFO -r a \ -p no:rerunfailures \ --ui-skip-build || true # Tar the snapshots tree (paths intact) so the commit job can restore it # wholesale. Only genuinely-changed/created PNGs differ from the committed # tree, so git add in the commit job stages exactly those. - name: Package baselines run: tar -czf "$RUNNER_TEMP/ui-snapshots.tgz" tests/e2e_ui/visual/snapshots - name: Upload regenerated baselines uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ui-snapshot-update-${{ github.run_id }} path: ${{ runner.temp }}/ui-snapshots.tgz if-no-files-found: error retention-days: 1 # 2) Commit + push on a clean runner. Runs NO PR code -- only checks out the # branch, drops in the rendered PNG, and pushes -- so it is safe to hold the # App token here. `git`/`gh` are preinstalled on ubuntu-latest. commit: name: Commit + push visual baselines needs: render # Run even if render failed, so we can still report on the PR + drop the # label; individual steps gate on the render outcome. (Skipped render => # label/guard didn't match => this is skipped too.) if: ${{ always() && needs.render.result != 'skipped' }} permissions: contents: write # push the regenerated baseline (GITHUB_TOKEN fallback) pull-requests: write # comment the result + drop the trigger label runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout PR branch if: needs.render.result == 'success' uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: # PR files land on disk but are never executed in this job; the push # token authenticates inline at the push step (not via .git/config). ref: ${{ github.event.pull_request.head.ref }} persist-credentials: false - name: Download regenerated baselines if: needs.render.result == 'success' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: ui-snapshot-update-${{ github.run_id }} path: _ui_snapshot_artifact - name: Restore the regenerated baselines if: needs.render.result == 'success' run: | tgz=$(find _ui_snapshot_artifact -type f -name 'ui-snapshots.tgz' | head -n1) if [ -z "$tgz" ]; then echo "error: no baseline archive in the render artifact." >&2 exit 1 fi # The archive holds the full tests/e2e_ui/visual/snapshots tree, so # extracting it over the checkout replaces EVERY baseline at its # committed path (a removed baseline drops out too). git add below # then stages whatever actually changed. rm -rf tests/e2e_ui/visual/snapshots tar -xzf "$tgz" rm -rf _ui_snapshot_artifact # Mint the App token in this no-PR-code job. Skipped when the App isn't # configured (push then falls back to GITHUB_TOKEN, which won't re-run CI). - name: Mint App token id: app-token if: needs.render.result == 'success' && vars.OMNIGENT_BOT_APP_ID != '' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: app-id: ${{ vars.OMNIGENT_BOT_APP_ID }} private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }} # The push token authenticates inline (scoped to this step, never in # .git/config). An App-token push re-fires the PR's checks; a GITHUB_TOKEN # fallback push does not. HEAD_REF (user-influenced) passes via env. - name: Commit + push the regenerated baseline id: push if: needs.render.result == 'success' env: HEAD_REF: ${{ github.event.pull_request.head.ref }} PUSH_TOKEN: ${{ steps.app-token.outputs.token || secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} run: | git config user.name "omnigent-ci[bot]" git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com" git add tests/e2e_ui/visual/snapshots if git diff --cached --quiet; then echo "changed=false" >> "$GITHUB_OUTPUT" echo "Baseline already matches this PR's render — nothing to commit." exit 0 fi git commit -m "test(e2e-ui): regenerate visual baselines" git push "https://x-access-token:${PUSH_TOKEN}@github.com/${REPO}.git" "HEAD:$HEAD_REF" echo "changed=true" >> "$GITHUB_OUTPUT" # Report on the PR thread and drop the label so it can be re-applied to # regenerate again. - name: Comment the result + drop the label if: ${{ always() && steps.push.conclusion == 'success' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} CHANGED: ${{ steps.push.outputs.changed }} # App token used → push re-triggers CI; skipped fallback → it won't. APP_USED: ${{ steps.app-token.conclusion == 'success' }} run: | if [ "$CHANGED" = "true" ]; then base="✅ Regenerated the visual baseline(s) in the pinned Playwright image and pushed to this PR." if [ "$APP_USED" = "true" ]; then body="$base CI will re-run on the new commit." else body="$base ⚠️ No bot App configured, so this push won't auto-trigger CI — push any commit to re-run checks." fi gh pr comment "$PR" --repo "$REPO" --body "$body" else gh pr comment "$PR" --repo "$REPO" \ --body "ℹ️ Baseline already matches this PR's render — nothing to update." fi gh pr edit "$PR" --repo "$REPO" --remove-label update-ui-snapshot || true # Failure path: render crashed or the push failed. Report on the PR (not # just the Actions tab) and still drop the label so the PR isn't stuck. - name: Comment on failure + drop the label if: ${{ always() && (needs.render.result == 'failure' || steps.push.conclusion == 'failure') }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} PR: ${{ github.event.pull_request.number }} REPO: ${{ github.repository }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | gh pr comment "$PR" --repo "$REPO" \ --body "❌ \`update-ui-snapshot\` failed — see the [workflow run]($RUN_URL). Baseline unchanged." gh pr edit "$PR" --repo "$REPO" --remove-label update-ui-snapshot || true