Skip to content

Commit ce2f58f

Browse files
christian-byrnepetros-double-test1ampagent
authored
ci: stop update-playwright-expectations failing open and cancelling other PRs (#14684)
Two independent defects in `pr-update-playwright-expectations.yaml`, both found while regenerating baselines for #14682 (the container-image bump). Both are specific to the `/update-playwright` comment trigger. ## Bug 1: the comment trigger fails open (silent false green) GitHub runs `issue_comment`-triggered workflows from the **default branch's** copy of the workflow file, not the PR's. The container image was a literal in that file, so `/update-playwright` on a PR that bumps the image tag regenerated snapshots under **main's** image (0.0.21), not the PR's (0.0.22). No pixels changed, nothing was committed, and the run reported **success**. A maintainer sees a green "baselines updated" result for work that never happened. The label trigger (`New Browser Test Expectations`) does not have this bug: `pull_request` events run the PR's own copy of the workflow. **Fix chosen: derive the tag from the PR's checked-out files at runtime**, rather than merely detecting the mismatch and failing. This makes the comment path *correct* instead of merely *loud* — a maintainer who comments `/update-playwright` on an image bump now gets working baselines instead of a red run telling them to go use the label. `jobs.<id>.container.image` accepts the `needs` context, so the `setup` job (which already checks out the PR branch) resolves the tag and the sharded job consumes it. The tag is read from the PR's `ci-tests-e2e.yaml` because that is the workflow which will *verify* these snapshots, so it defines the environment they must match. Matching is anchored to `image:` value lines, and all containerized jobs in that file must agree on one tag - `ci-tests-e2e.yaml` has two (`playwright-tests` and `playwright-tests-chromium-sharded`), and requiring agreement catches them drifting apart. Resolution is fail-closed: a missing file, zero matches, or conflicting tags all `::error::` and fail the job. As a side effect this workflow no longer carries an image literal, so future bumps touch two files instead of three. The two rejected options: detecting the mismatch and failing loudly leaves the comment path unusable for exactly the PRs that most need it; removing the comment trigger discards a workflow maintainers actively use. **Residual risk, now surfaced rather than silent:** only the image is taken from the PR. Everything else in this file (job graph, shard matrix, `run` commands) still comes from the default branch on the comment path. A new non-fatal step emits a `::warning::` when the PR modifies this workflow, pointing the maintainer at the label trigger. It cannot fail the job — a failed `git fetch` degrades to a `::notice::` and exits 0. ## Bug 2: concurrency group collides across PRs `concurrency.group` keyed on `github.ref`, which for `issue_comment` is always the default branch. Every comment-triggered run therefore shared one group with `cancel-in-progress: true`, so a comment on **any** PR cancelled an in-flight regeneration on **any other** PR. This actually happened. Note the blast radius is wider than it looks: workflow-level concurrency is evaluated when the run is created, before the job-level `if:` guard filters non-matching comments, so an ordinary comment on an unrelated issue also entered the group. Now keyed on the PR number, with a fallback that is valid for every declared trigger: ``` ${{ github.workflow }}-${{ github.event.issue.number || github.event.number || github.ref }} ``` `github.event.issue.number` for `issue_comment`, `github.event.number` for `pull_request` (`github.event.issue` is absent there and dereferences to null), `github.ref` as a last resort. The label path was already correctly isolated (`refs/pull/N/merge`) and keeps per-PR isolation under the new key. ## Verification Cannot be exercised end-to-end without merging — `issue_comment` workflows only ever run from the default branch, so the fixed comment path is unreachable until this lands. Same structural constraint as #13699 (a different workflow, `pr-backport.yaml`, unrelated defect). Validated as far as statically possible: - `yamllint --config-file .yamllint` (the repo's `CI: YAML Validation` gate) passes. - `actionlint` v1.7.11 reports **no** expression or context errors. It emits the same 6 pre-existing `SC2086:info` findings as the file on `main` — zero new findings. This is what checks expression validity per trigger. - `bash -n` passes on every inline script. - The image-resolution script was **executed** against 7 real inputs: `main`'s `ci-tests-e2e.yaml` -> `0.0.21`; **#14682's branch -> `0.0.22`** (the bug, directly demonstrated); a decoy tag inside a YAML comment -> still `0.0.21`; a quoted `image:` value -> `0.0.21`; missing file -> error, exit 1; two conflicting tags -> error, exit 1; no tag -> error, exit 1. - The drift-warning script was executed in a throwaway repo: no drift -> silent; modified workflow -> `::warning::`; unreachable remote -> `::notice::`, exit 0. **Unverifiable until merge:** that GitHub actually accepts `needs.*` in `container.image` at runtime (documented and widely used, but not exercised here); that the resolved image pulls under `credentials`; and the real end-to-end comment-triggered regeneration. ## Other fail-open path, not fixed here `Update snapshots (Shard N)` carries `continue-on-error: true`. That is necessary — `--update-snapshots` exits non-zero when it rewrites a baseline — but it also swallows hard failures. If a shard dies for an unrelated reason (ComfyUI server never boots, container broken, Playwright crashes), zero snapshots change, `merge-and-commit` logs "No changes to commit", and the workflow goes green having done nothing. That is the same silent-false-green shape as Bug 1 and survives this PR; distinguishing "no diffs" from "the run never happened" needs the Playwright result JSON and is a larger change. Flagging for a follow-up rather than bundling it. --------- Co-authored-by: t <t@t.t> Co-authored-by: Amp <amp@ampcode.com>
1 parent 4a4b1e4 commit ce2f58f

1 file changed

Lines changed: 206 additions & 5 deletions

File tree

.github/workflows/pr-update-playwright-expectations.yaml

Lines changed: 206 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ on:
77
issue_comment:
88
types: [created]
99

10+
# github.ref is the default branch for issue_comment events. Both triggers use
11+
# the PR number because they push snapshots to the same branch.
1012
concurrency:
11-
group: ${{ github.workflow }}-${{ github.ref }}
13+
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.number || github.ref }}
1214
cancel-in-progress: true
1315

1416
jobs:
@@ -28,6 +30,7 @@ jobs:
2830
pr-number: ${{ steps.pr-info.outputs.pr-number }}
2931
branch: ${{ steps.pr-info.outputs.branch }}
3032
comment-id: ${{ steps.find-update-comment.outputs.comment-id }}
33+
container-image: ${{ steps.container-image.outputs.image }}
3134
steps:
3235
- name: Get PR info
3336
id: pr-info
@@ -59,6 +62,55 @@ jobs:
5962
uses: actions/checkout@v7
6063
with:
6164
ref: ${{ steps.pr-info.outputs.branch }}
65+
66+
# issue_comment loads this workflow from the default branch. Resolve the
67+
# image from the checked-out PR so regenerated snapshots match its CI.
68+
- name: Resolve CI container image from PR branch
69+
id: container-image
70+
shell: bash
71+
run: |
72+
set -euo pipefail
73+
74+
e2e_workflow='.github/workflows/ci-tests-e2e.yaml'
75+
if [ ! -f "$e2e_workflow" ]; then
76+
echo "::error::${e2e_workflow} not found on this PR branch; cannot resolve the CI container image"
77+
exit 1
78+
fi
79+
80+
# Require every E2E container to use the same static image.
81+
mapfile -t images < <(
82+
grep -oE "^[[:space:]]*image:[[:space:]]*['\"]?ghcr\.io/comfy-org/comfyui-ci-container:[A-Za-z0-9._-]+" "$e2e_workflow" |
83+
grep -oE 'ghcr\.io/comfy-org/comfyui-ci-container:[A-Za-z0-9._-]+' |
84+
sort -u
85+
)
86+
if [ "${#images[@]}" -ne 1 ]; then
87+
echo "::error::Expected exactly one comfyui-ci-container image across the container jobs in ${e2e_workflow}, found ${#images[@]}: ${images[*]:-none}"
88+
exit 1
89+
fi
90+
91+
echo "Regenerating snapshots with ${images[0]} (resolved from the PR branch)"
92+
echo "image=${images[0]}" >> "$GITHUB_OUTPUT"
93+
94+
# Other workflow changes still come from the default branch on this path.
95+
- name: Warn when this workflow differs from the default branch
96+
if: github.event_name == 'issue_comment'
97+
shell: bash
98+
env:
99+
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
100+
run: |
101+
set -euo pipefail
102+
103+
workflow='.github/workflows/pr-update-playwright-expectations.yaml'
104+
105+
if ! git fetch --depth=1 origin "$DEFAULT_BRANCH"; then
106+
echo "::notice::Could not fetch ${DEFAULT_BRANCH}; skipping workflow drift check"
107+
exit 0
108+
fi
109+
110+
if ! git diff --quiet FETCH_HEAD -- "$workflow"; then
111+
echo "::warning::This PR modifies ${workflow}, but /update-playwright runs the ${DEFAULT_BRANCH} copy of it. Only the container image is taken from the PR; other changes to this workflow are NOT in effect for this run. Use the 'New Browser Test Expectations' label instead to run the PR's own copy."
112+
fi
113+
62114
- name: Setup frontend
63115
uses: ./.github/actions/setup-frontend
64116
with:
@@ -81,7 +133,7 @@ jobs:
81133
needs: setup
82134
runs-on: ubuntu-latest
83135
container:
84-
image: ghcr.io/comfy-org/comfyui-ci-container:0.0.22
136+
image: ${{ needs.setup.outputs.container-image }}
85137
credentials:
86138
username: ${{ github.actor }}
87139
password: ${{ secrets.GITHUB_TOKEN }}
@@ -109,10 +161,12 @@ jobs:
109161
- name: Install frontend deps
110162
run: pnpm install --frozen-lockfile
111163

112-
# Run tests with snapshot updates (browsers pre-installed in container)
164+
# Snapshot rewrites fail the test command, so validate its JSON report.
113165
- name: Update snapshots (${{ matrix.shard == 'cloud' && 'cloud' || format('Shard {0}/4', matrix.shard) }})
114166
id: playwright-tests
115-
run: pnpm exec playwright test --update-snapshots --grep @screenshot ${{ matrix.shard == 'cloud' && '--project=cloud' || format('--grep-invert @cloud --shard={0}/4', matrix.shard) }}
167+
env:
168+
PLAYWRIGHT_JSON_OUTPUT_NAME: /tmp/playwright-results.json
169+
run: pnpm exec playwright test --update-snapshots --reporter=json,html --grep @screenshot ${{ matrix.shard == 'cloud' && '--project=cloud' || format('--grep-invert @cloud --shard={0}/4', matrix.shard) }}
116170
continue-on-error: true
117171

118172
- name: Stage changed snapshot files
@@ -150,6 +204,63 @@ jobs:
150204
cp "$file" "/tmp/changed_snapshots_shard/$file_without_prefix"
151205
done <<< "$changed_files"
152206
207+
# Reject test failures that produced no rewritten snapshots.
208+
- name: Verify shard did the work it claims
209+
shell: bash
210+
env:
211+
SHARD: ${{ matrix.shard }}
212+
HAS_CHANGES: ${{ steps.changed-snapshots.outputs.has-changes }}
213+
run: |
214+
set -uo pipefail
215+
216+
results='/tmp/playwright-results.json'
217+
218+
# Playwright still writes a report when zero tests match.
219+
if [ ! -f "$results" ]; then
220+
echo "::error::Shard ${SHARD}: Playwright wrote no JSON report, so it never completed a run. Refusing to report success."
221+
exit 1
222+
fi
223+
224+
# Node is available in the CI container; jq may not be.
225+
if ! summary=$(node -e '
226+
const fs = require("fs");
227+
const r = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
228+
const s = r.stats;
229+
if (!s) throw new Error("no stats in report");
230+
const n = (v) => (typeof v === "number" ? v : 0);
231+
const notPassing = n(s.unexpected) + n(s.flaky);
232+
const ran = n(s.expected) + notPassing;
233+
const errs = Array.isArray(r.errors) ? r.errors.length : 0;
234+
console.log(ran + " " + notPassing + " " + errs);
235+
console.log(JSON.stringify(s));
236+
' "$results" 2>&1); then
237+
echo "::error::Shard ${SHARD}: Playwright JSON report is unreadable or has no stats. Refusing to report success."
238+
echo "$summary"
239+
exit 1
240+
fi
241+
242+
read -r ran not_passing error_count <<< "$(printf '%s\n' "$summary" | head -1)"
243+
echo "Shard ${SHARD} stats: $(printf '%s\n' "$summary" | tail -1)"
244+
245+
# An empty grep result is valid despite Playwright's non-zero exit.
246+
if [ "$ran" -eq 0 ]; then
247+
echo "::notice::Shard ${SHARD}: no tests matched this shard's filters; nothing to regenerate."
248+
exit 0
249+
fi
250+
251+
if [ "$error_count" -gt 0 ]; then
252+
echo "::error::Shard ${SHARD}: Playwright reported ${error_count} top-level error(s); the regenerated baselines may be incomplete."
253+
exit 1
254+
fi
255+
256+
# Snapshot rewrites appear as unexpected or flaky tests.
257+
if [ "$not_passing" -gt 0 ] && [ "${HAS_CHANGES:-false}" != 'true' ]; then
258+
echo "::error::Shard ${SHARD}: ${not_passing} test(s) did not pass yet no snapshot was rewritten. That is a broken run, not an absence of drift. Refusing to report success."
259+
exit 1
260+
fi
261+
262+
echo "Shard ${SHARD}: ${ran} test(s) ran, ${not_passing} not passing, snapshot changes=${HAS_CHANGES:-false}"
263+
153264
# Upload ONLY the changed files from this shard
154265
- name: Upload changed snapshots
155266
uses: actions/upload-artifact@v6
@@ -270,6 +381,82 @@ jobs:
270381
echo "$CHANGES" | wc -l
271382
fi
272383
384+
# Flag small rewrites that are likely text-rasterization noise rather than
385+
# intentional visual changes.
386+
- name: Report snapshot diff magnitudes
387+
id: diff-report
388+
shell: bash
389+
run: |
390+
set -uo pipefail
391+
392+
report='/tmp/diff-report.md'
393+
: > "$report"
394+
395+
mapfile -t changed < <(
396+
git status --porcelain=v1 --untracked-files=all -- browser_tests/ |
397+
sed -e 's/^...//' -e 's/^"//' -e 's/"$//'
398+
)
399+
if [ "${#changed[@]}" -eq 0 ]; then
400+
echo "No snapshot changes to measure"
401+
exit 0
402+
fi
403+
404+
if command -v compare >/dev/null 2>&1; then
405+
echo "Measuring ${#changed[@]} rewritten snapshot(s) with ImageMagick"
406+
else
407+
echo "::warning::ImageMagick 'compare' unavailable; falling back to byte sizes, which cannot distinguish a real visual change from rasterization noise."
408+
fi
409+
410+
small=0
411+
{
412+
echo "| snapshot | status | changed pixels |"
413+
echo "| --- | --- | --- |"
414+
} >> "$report"
415+
416+
tmp_old="$(mktemp)"
417+
for f in "${changed[@]}"; do
418+
[ -f "$f" ] || continue
419+
name="${f#browser_tests/}"
420+
421+
if ! git show "HEAD:$f" > "$tmp_old" 2>/dev/null; then
422+
echo "| \`${name}\` | new | n/a |" >> "$report"
423+
continue
424+
fi
425+
426+
px=''
427+
if command -v compare >/dev/null 2>&1; then
428+
px=$(compare -metric AE "$tmp_old" "$f" null: 2>&1 >/dev/null)
429+
# Non-numeric means differing geometry or a decode failure.
430+
case "$px" in
431+
''|*[!0-9]*) px='' ;;
432+
esac
433+
fi
434+
435+
if [ -n "$px" ]; then
436+
echo "| \`${name}\` | modified | ${px} |" >> "$report"
437+
# The threshold is advisory because small changes can be valid.
438+
if [ "$px" -gt 0 ] && [ "$px" -le 2000 ]; then
439+
small=$((small + 1))
440+
fi
441+
else
442+
old_b=$(wc -c < "$tmp_old" | tr -d ' ')
443+
new_b=$(wc -c < "$f" | tr -d ' ')
444+
echo "| \`${name}\` | modified | ${old_b}B -> ${new_b}B |" >> "$report"
445+
fi
446+
done
447+
rm -f "$tmp_old"
448+
449+
if [ "$small" -gt 0 ]; then
450+
echo "::warning::${small} rewritten baseline(s) changed by <=2000 pixels. Screenshot capture is not bit-deterministic - small diffs are usually text rasterization noise, not an intended change. Confirm each rewrite corresponds to a test you expected to change before merging."
451+
fi
452+
453+
cat "$report"
454+
{
455+
echo "### Rewritten snapshots (${#changed[@]})"
456+
echo
457+
cat "$report"
458+
} >> "$GITHUB_STEP_SUMMARY"
459+
273460
- name: Commit updated expectations
274461
id: commit
275462
run: |
@@ -289,7 +476,21 @@ jobs:
289476
echo "has-changes=true" >> $GITHUB_OUTPUT
290477
291478
git add browser_tests/
292-
git commit -m "[automated] Update test expectations"
479+
480+
# Use cat so backticks and file names remain literal.
481+
{
482+
echo '[automated] Update test expectations'
483+
if [ -s /tmp/diff-report.md ]; then
484+
echo
485+
cat /tmp/diff-report.md
486+
echo
487+
echo 'Screenshot capture is not bit-deterministic; small pixel'
488+
echo 'deltas are usually text rasterization noise. Confirm each'
489+
echo 'rewrite corresponds to a test expected to change.'
490+
fi
491+
} > /tmp/commit-msg.txt
492+
493+
git commit -F /tmp/commit-msg.txt
293494
294495
echo "Pushing to ${{ needs.setup.outputs.branch }}..."
295496
git push origin ${{ needs.setup.outputs.branch }}

0 commit comments

Comments
 (0)