Skip to content

A failing playwright-seed-command silently skips its && tail, exits 0, and prints 'Seed command completed.' — Playwright then tests an unseeded instance #192

Description

@rubenvdlinde

Summary

playwright-seed-command is eval'd with an unquoted interpolation, so any && in a repo's seed command is parsed by the step's own shell rather than by eval. Combined with bash's set -e exemption for && lists, a seed command whose first stage fails will:

  1. silently skip every later stage,
  2. not fail the step, and
  3. print Seed command completed. — a statement that is false.

Playwright then runs against a half-seeded instance and produces a full, credible-looking tally.

The code

.github/workflows/quality.yml@main, "Seed test data" step (around line 2422):

run: |
  cd server
  echo "Running seed command: ${{ inputs.playwright-seed-command }}"
  eval ${{ inputs.playwright-seed-command }}
  echo "Seed command completed."

eval has no quotes around the interpolation, so for an input like a.sh && b && c the shell parses eval a.sh && b && c. eval only ever receives the first stage.

The set -e exemption

GitHub's default shell for run: is bash -e {0}, which usually means a failing command aborts the step. It does not here. From the bash manual, set -e is suppressed for a command that is "part of any command executed in a && or || list except the command following the final && or ||". The failing first stage is exempt, and the final stage never runs at all, so nothing is ever eligible to trigger the exit.

Demonstrated:

$ bash -e -c 'echo "Running seed command: X"; false && echo "TAIL RAN"; echo "Seed command completed."'
Running seed command: X
Seed command completed.
$ echo $?
0

TAIL RAN is absent, and the step exits 0.

Measured in the wild

ConductionNL/pipelinq, run 30800304506 (branch ci/bundle-truncation-control, 2026-08-03), E2E job 91645954167. Its seed command was:

bash apps/pipelinq/tests/e2e/ci-seed.sh && echo "[control] bundle before: $(stat -c%s apps/pipelinq/js/pipelinq-main.js) bytes" && truncate -s 65 apps/pipelinq/js/pipelinq-main.js && echo "[control] bundle after: $(stat -c%s apps/pipelinq/js/pipelinq-main.js) bytes"

ci-seed.sh failed:

##[error]These setup steps are still unmet: ['organisation'].
Seed command completed.

so the truncate never ran. The branch existed for one purpose — to run the suite against a zeroed JS bundle as a positive control — and it ran against a full bundle on a half-seeded instance instead. It then reported 107 passed / 61 failed / 41 skipped / 1 did not run out of 211, a tally with the shape of a real experimental result and no relationship to the experiment.

The control's own readback could not have caught it either. $(stat …) sits inside the double-quoted argument of the echo "Running seed command: …" line, where nested quotes end the outer quoting, so both substitutions are expanded by that echo — before any truncation. The log shows:

Running seed command: … && echo [control] bundle before: 3299693 bytes && truncate -s 65 … && echo [control] bundle after: 3299693 bytes

Identical before and after, by construction. A successful truncation would have printed exactly the same line.

Why it matters generally

This is not one repo's mistake. && is the natural way to write a multi-stage seed, the step's own log line actively encourages treating the input as a shell fragment, and the failure is invisible three ways over: no non-zero exit, a success message, and a plausible test tally. Any repo whose playwright-seed-command chains stages is one failing stage away from silently testing an unseeded instance — and the resulting failures will be attributed to application code.

Suggested fix

  1. Quote the interpolation: eval "${{ inputs.playwright-seed-command }}" so the whole command reaches eval as one unit and its combined exit status is the step's.
  2. Check the status explicitly rather than relying on set -e:
    eval "${{ inputs.playwright-seed-command }}" && RC=0 || RC=$?
    if [ "$RC" -ne 0 ]; then
      echo "::error::Seed command failed with exit ${RC}. Not running Playwright against an unseeded instance."
      exit "$RC"
    fi
    echo "Seed command completed."
    The && RC=0 || RC=$? idiom is already used elsewhere in this file (the axe and hydra-gates runners) for the same reason.
  3. Only print Seed command completed. when it did.
  4. Consider passing the input through an intermediate variable rather than interpolating it into the script body, so a seed command containing quotes cannot reshape the surrounding shell.

A test that proves the gate can fail: a seed command of false && true must turn the job red. Today it turns it green.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions