Skip to content

finding(ci): two workflow steps discard a piped command's exit status into a Playwright cache key — playwright-Linux- or -null on failure, and the scoping rule is now measured (3 hits repo-wide, 2 real) #6231

Description

@yinlianghui-tw

Filed by the domain:devx @ objectui execution PM seat (#5748), PM session session_019b5UBNMtTzKbVtZZGvFuxe. This is the follow-up #6221 deferred, now filable because the scoping rule has been measured rather than guessed.

The two instances

Verified on origin/main — byte-identical lines in two files:

  • .github/workflows/ci.yml:1010
  • .github/workflows/live-e2e.yml:140
run: echo "version=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_OUTPUT

The exit status is discarded twice over: echo owns the step's status, and there is a pipe inside the substitution whose status is jq's. Either failure yields an empty or nullversion and a successful step.

The value feeds the next step's cache key:

key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}

so a failure degrades it to playwright-Linux- or playwright-Linux-null. ⚠️jq -r prints the string null and exits 0 for a missing field, so a renamed or moved key in pnpm list's JSON produces the -null bucket with every command in the pipeline reporting success.

Consequence, stated at its real size

This is milder than #6221's unfiltered build and should not be sold as urgent. A wrong cache bucket means a cache miss — browsers get re-downloaded, the job is slower, nothing is incorrect. The sharper risk is the stable-wrong bucket: playwright-Linux-null is a valid key that persists, so two different Playwright versions could share one cache entry and a stale browser could be restored. No evidence that has happened.

⭐ The scoping rule, measured — this is why the card is filable now

#6221 deliberately did not add a repo-wide scan, on the grounds that a naive $( sweep would be almost all false positives and a gate whose findings are mostly noise gets ignored. That caution is now a measurement, taken by the #6221 dev across .github/workflows/**:

predicatehitsreal
any command substitution outside comments38, across 27 filesalmost all false positives — the caution was right
a command substitution whose whole value is interpolated into an echo appending to $GITHUB_OUTPUT3 repo-wide2 (the pair above)

The third hit is benign: performance-budget.yml:138, echo "entry_file=$(basename $ENTRY_FILE)"basename on an already-validated path.

And the forms that are safe are safe by the repo's own convention, which a gate must not flag:

  • a bare X=$(cmd) assignment does propagate its status under set -e;
  • the explicitly-checked if ! CHANGED=$(git diff …) form appears five times (ci.yml ×4, lint.yml).

So the narrow predicate has a 3-hit surface with a 2/3 true-positive rate, against 38 hits at near-zero. That is the difference between a gate worth writing and one that gets deleted.

Possible dispositions (not chosen here)

  1. Fix the two instances only. Capture the version into a variable with its status checked (set -o pipefailis relevant here — unlike finding(ci): doc-snippet-types.yml's --build-filter step discards the gate's exit code inside a command substitution, so a failed filter yields an empty args and a step that succeeds #6221 there really is a pipe), fail the step on non-zero, and additionally refuse an empty or null version before it reaches a cache key.
  2. Fix them and add the narrow gate, using the measured predicate above rather than a $( sweep. ⚠️ Whoever scopes it must keep the two safe conventions passing — a gate that reds on if ! CHANGED=$(git diff …) would be reverted the same day.
  3. Fix the two and record the predicate in the CI guide without automating it.

⚠️ Note the two lines are identical, in two workflows, which is itself the argument for either 2 or a shared step: the copy already happened once.

Bound

⛔ Not a defect in PR #6228 (#6221), which fixed a different and more serious instance and correctly declined to widen. ⛔ No evidence either line has ever failed. Filed because the mechanism is real, the failure is silent, and — unlike an hour ago — the scan that would catch the class is now a specified predicate with a measured false-positive surface.

Refs: #6221 / PR #6228 (the same defect class, one layer more serious, plus the measurement that scoped this) · #5465 / PR #6215 (the | tail instance that started this family and falsified a card's premise).

Metadata

Metadata

Labels

ci/cddomain:devxobjectui devx stream: fix lands on .github/, scripts/ or release pipeline — devx lane cross-repo

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions