261 lines
12 KiB
YAML
261 lines
12 KiB
YAML
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
|