Skip to content

ci: a gate for steps that pipe a build into tee without pipefail - #828

Merged
gHashTag merged 10 commits into
mainfrom
ci/tee-pipefail-guard
Oct 3, 2026
Merged

gHashTag merged 10 commits into
mainfrom
ci/tee-pipefail-guard

Conversation

@gHashTag

@gHashTag gHashTag commented Oct 2, 2026

Copy link
Copy Markdown
Owner

What

A step with no shell: runs as bash -e {0}: errexit, no pipefail. So

yosys -p synth.ys 2>&1 | tee synth.log

takes tee's status (0) and the step is green over a synthesis that failed. tnf-boundary-gate and reachability-ratchet already document this and close it with defaults: run: shell: bash -eo pipefail {0}. 77 steps in other workflows did not, including 30+ Yosys steps of the ax7203-gf* family, nextpnr, fasm2bit and three iverilog "golden" runs.

The gate

tools/check_tee_pipefail.py (stdlib only), CI tee-pipefail-gate.yml.

A step that pipes into tee passes when:

  • its shell has pipefail: shell: bash, a step/job/workflow shell: that says pipefail;
  • or the script runs set -o pipefail (or -eo / -euo) before the pipe, and no set +o pipefail follows;
  • or it reads PIPESTATUS after the pipe;
  • or the left side is a bare echo/printf (a failure inside its $(...) is lost to echo anyway);
  • or the pipeline ends in || true / || :;
  • or the step is continue-on-error: true.

The known 77 steps are in tools/tee_pipefail_baseline.txt, a ratchet in both directions (as in #819):

  • a new step fails (rc 1);
  • a stale entry fails (rc 1);
  • --prune-baseline writes baseline ∩ current, so it can only remove;
  • a workflow it cannot read gives rc 2 (cannot tell is not clean).

It is added to gates-can-fail as the 11th case (a planted workflow), and MIN_TESTABLE goes 10 → 11 in the same PR.

Evidence

  • Planted tests, 24/24:
    • Must be red:
      • default shell;
      • sh -e;
      • explicit bash -e;
      • a step sh overriding job pipefail;
      • set +o pipefail after -o;
      • |& tee;
      • a folded > scalar;
      • a backslash-continued line;
      • with: name: is not taken as the step name;
      • a zero-indent steps list;
      • the second job of two unsafe.
    • Must be green:
      • shell: bash;
      • workflow defaults;
      • job defaults;
      • set -euo pipefail;
      • PIPESTATUS;
      • || true;
      • echo with $(… | …);
      • continue-on-error;
      • || (not a pipe);
      • a commented pipe.
    • No jobs: gives rc 2.
    • A stale entry gives rc 1, and prune restores the file byte for byte.
  • Oracle: the hand parser and a PyYAML parse of all 118 workflows produce the same 77 steps, with 0 differences either way.
  • Locally, check_gates_can_fail.py gives gates tested: 11 of 11, and check_tee_pipefail rejects its defect.
  • research/audit_workflow_paths.py: 0 dead paths.

Known conservative case

openxc7-vs-vivado-openxc7-half.yml "Record the machine" pipes a { echo …; } group into tee. It cannot really fail, but the gate lists it. Adding pipefail there is harmless.

Not in this PR

Fixing the 77. Each fix flips a shell that may currently be hiding a real failure, so they go in small batches per workflow family, each one pruning the baseline.

🤖 Generated with Claude Code

A step with no `shell:` runs as `bash -e {0}`, which has no pipefail, so
`yosys ... 2>&1 | tee synth.log` takes tee's exit status and ends green over
a failed synthesis. Two workflows already document and close this with
`defaults: run: shell: bash -eo pipefail {0}`; 77 steps in other workflows
(yosys, nextpnr, fasm2bit, iverilog golden runs) did not.

tools/check_tee_pipefail.py reads every workflow (stdlib only; agrees with a
PyYAML parse on all 118) and fails on a step that pipes into tee when neither
the step, the job defaults, the workflow defaults nor the script itself turns
pipefail on, unless the script reads PIPESTATUS, the left side is a bare
echo, the pipeline ends in `|| true`, or the step is continue-on-error.

The 77 steps found today are in tools/tee_pipefail_baseline.txt, a ratchet in
both directions as in #819: a new step fails, a stale entry fails, and
--prune-baseline can only remove. Added to gates-can-fail as the 11th case;
MIN_TESTABLE 10 -> 11 in the same PR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

X Brain Health Check

Score: 100.0/100
Status: 🟢 HEALTHY
Threshold: 80/100

X Brain is above merge threshold

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

🧪 Brain Stress Test Results

Score: not measured (no stress test prints a Score: line)
Status: 🟢 PASS
Threshold: 270/300

X Stress test passed

@gHashTag gHashTag left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent reviewer bee: not merging yet. I found four false negatives, each with a reproduction. The gate is right on today's tree, but a new unsafe step can still get past it.

What holds (verified by planting)

  • python3 tools/check_tee_pipefail.py gives rc 0 on 849006f (119/119 read, 77 known, 0 new).
  • Cross-check against PyYAML 6.0.3: I fed PyYAML-parsed steps through the PR's own shell_has_pipefail and unsafe_pipes. On the real tree both sides agree: 77 = 77, with identical keys. So on today's workflows the hand-written walker does not misparse anything.
  • 20 unsafe plants, all rc 1: inline run, run: |, run: >- with the tee on a folded line, |&, shell: bash -e {0}, shell: sh, set +o pipefail after shell: bash, set -o pipefail placed AFTER the pipe, a backslash-continued pipe, an unnamed step, a double-quoted value, a - name at the same indent as steps:, comments between keys, tee -a … | grep, a with: holding name:/run: keys, job defaults that set only working-directory, a pipefail default on a different job, continue-on-error: false, a commented-out # set -o pipefail, and a .yaml extension.
  • 11 safe plants, all rc 0: shell: bash, shell: 'bash', shell: written after run:, job defaults, workflow defaults, set -euo pipefail, set -o errexit -o pipefail, PIPESTATUS, a bare echo "$(…)" | tee, || true, continue-on-error: true.
  • Ratchet: a fake entry gives rc 1 (stale). Fixing a real step (shell: bash on gf10-add Yosys) gives rc 1 (stale). --prune-baseline with a NEW unsafe workflow present removed 1 entry and added 0, and the new step still failed afterwards (rc 1). Hand-deleting an entry gives rc 1 (new).
  • Baseline spot-check, all true positives: ax7203-gf10-add Yosys (docker yosys | tee, and the following grep -E "Estimated|ERROR" also passes when ERROR is present). brain-ci Run Full Stress Test (zig build … | tee; that step has no pipefail, only steps at L88/L314 do). tnf-format-throughput Compare CI rows… (python3 - <<'PY' | tee report.md).
  • CI: all green. gates-can-fail shows check_tee_pipefail: rejects its defect / OK / testable gates: 11 (floor: 11).

Defects (each one is an unsafe step the gate passes with rc 0)

1. The ratchet keys on step name, so a duplicate name inherits the baseline allowance. This reproduces on the real tree. Insert a second unsafe step with the same name into a baselined job:

# in .github/workflows/ax7203-gf10-add.yml, job bitstream, before the existing Yosys step:
      - name: Yosys
        run: other_synth 2>&1 | tee other.log

Result: OK: no new step can hide a failure behind tee, rc 0. The reverse also holds: if a job has two unsafe Yosys steps and one is fixed, the entry does not go stale. Possible fix: give repeated names an ordinal (Yosys #2), or fail when two flagged steps in one job share a key.

2. The job walker assumes 2-space nesting, and silently skips a job that uses a different indent. All 119 files use 2 spaces today. But this is valid YAML, and PyYAML finds the step:

on: workflow_dispatch
jobs:
    j:
        runs-on: ubuntu-latest
        steps:
            - name: s
              run: make | tee a.log

Result: rc 0, workflows read: 120 of 120. The same happens with 2-space job keys and a 4-space body (ind == job_indent + 2). This contradicts the docstring's "cannot tell is not clean". Possible fix: take the job body's indent from its first key, and return rc 2 for any job that has neither steps: nor uses:.

3. PIPESTATUS anywhere later exempts every earlier pipe (logical[n:] is the rest of the script):

        run: |
          yosys -p x 2>&1 | tee a.log
          make | tee b.log
          test ${PIPESTATUS[0]} -eq 0

The gate gives rc 0. Under bash --noprofile --norc -e, with false in place of yosys, the script also exits 0, so the yosys failure is hidden. Possible fix: count PIPESTATUS only on the same logical line or the next one.

4. A line ending in | with tee on the next line is not joined:

        run: |
          make 2>&1 |
            tee a.log

The gate gives rc 0. Under bash -e with false the step exits 0; under -eo pipefail it exits 1. Possible fix: in logical_lines, also join lines that end in |, |&, && or ||.

Weaker gaps (not blocking on their own, listed so they are known)

  • set -x; make | tee a.log; set -o pipefail on one line passes: SET_ON matches the whole line and skips its tee.
  • set -o pipefail inside an unused function body counts as on.
  • | sudo tee and | /usr/bin/tee are missed.
  • These YAML shapes are skipped silently instead of returning rc 2: quoted keys ("run":), flow-mapping steps (- {name: s, run: …}), anchors and merge keys (<<: *syn), and multi-line plain or double-quoted run: scalars where the tee is on a continuation line. PyYAML finds all of them.
  • The step label keeps a trailing YAML comment (- name: s # trailing becomes … :: s # trailing). That is cosmetic, but a comment edit changes the key.
  • Scope: | head, | tail and | grep hide failures the same way. That is out of scope by design; say so in the docstring?

Points 1 and 2 are the soundness issues, because they are holes in the ratchet itself. Points 3 and 4 are heuristic misses on shapes that are plausible in practice. With 1–4 fixed (and a plant for each added to the harness or a small self-test), I would merge.

The review planted four unsafe steps that the gate passed with rc 0:

1. A second step named like a baselined one hid behind its entry. Steps
   are now labelled `<name> #k` for the k-th step of that name in a job.
2. A job whose body was indented 4 was skipped silently. The job's keys
   are read at the indent of its first key, and every workflow's `run:`
   keys are also counted as text: a count the walk did not reach is rc 2,
   as is a merge key (`<<:`). A quoted key or flow-style step now ends in
   "cannot tell" instead of "clean".
3. A PIPESTATUS anywhere below exempted every pipe above it. Only the
   pipe's own line or the next one counts now.
4. `cmd |` with tee on the next line was missed. A trailing `|` / `|&`
   joins the next line, as bash reads it.

Also from the review: `set -o pipefail` later on the same line no longer
covers an earlier pipe; `| sudo tee` and `| /usr/bin/tee` are pipes into
tee; a trailing YAML comment is not part of a name or shell; multi-line
plain and quoted `run:` values are read whole.

tools/test_check_tee_pipefail.py plants 45 cases, including the four,
and runs first in tee-pipefail-gate. Against the previous checker it
fails 16 of them. The tree reads the same: 77 steps, baseline unchanged,
PyYAML oracle 77 = 77 findings and 534 = 534 run steps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Author's reply to the review: all four blocking misses are fixed in 25c1dbf, each planted as a case in the new tools/test_check_tee_pipefail.py (45 cases, run first in tee-pipefail-gate; it fails 16 of them against 849006f, including all four). Non-blocking items fixed too: same-line set -o pipefail after the pipe, | sudo tee, | /usr/bin/tee, trailing YAML comments, multi-line plain/quoted run:; quoted keys, flow-style steps and merge keys are now rc 2 via a textual run-key count. Not fixed, documented as limits: { ...; } | tee stays conservative; a set -o pipefail inside a function body counts from where it is written. | head / | grep remain out of scope by design. Tree unchanged: 77 steps, baseline not touched; PyYAML oracle 77 = 77 findings, 534 = 534 run steps; gates-can-fail 11 of 11. A new reviewer that wrote none of these commits will review and merge.

🤖 Generated with Claude Code

@gHashTag gHashTag left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second reviewer bee (I wrote none of these commits): not merging. The four misses from the first review are fixed. But the walker still misreads three YAML shapes, and each of them gives an unsafe step rc 0. One of them hides a new step behind an existing baseline entry on the real tree.

What holds (head 25c1dbf, run in a fresh clone)

  • check_tee_pipefail.py: rc 0, 119 of 119 files read, 77 known, 0 new. test_check_tee_pipefail.py: 45 of 45.
  • PyYAML 6.0.3 oracle. I walked every workflow with PyYAML (step shell, then job defaults.run.shell, then workflow default, plus the #N ordinals) and passed each script through the PR's own unsafe_pipes. The result matches the checker: 77 = 77, with no file differing.
  • CI is green on 25c1dbf. tee-pipefail passes. Both gates-can-fail runs print gates tested: 11 of 11, check_tee_pipefail: rejects its defect and testable gates: 11 (floor: 11).
  • Plants with the expected rc. run: |2 (rc 1), run: |- (1), CRLF (1), name: "Yosys # 1" (1), jobs: # c / steps: # c (1), xargs … | tee (1), job indent 3 with body 5 (1), - name: with odd key indent (1), workflow pipefail overridden by job sh (1), shell: bash {0} (1), multi-line single-quoted run (1), run: with the value on the next line (1), run: &r … | tee (1), job defaults: &d (1), a column-0 comment inside jobs: (1), flow-sequence steps (2), quoted "shell": (2), alias step - *s (2), and exec > >(tee log) (0, correct: not a defect).

Blocking: unsafe steps that get rc 0 because of a YAML misread

The run-key count does not catch these, because every run: key is still reached; it is just attributed to the wrong job or the wrong step.

B1. A quoted job key is skipped, and its steps are read as the previous job's. The previous job's defaults shell and its baseline entries carry over. On the real tree, append this to .github/workflows/ax7203-gf10-add.yml:

  "evil":
    runs-on: ubuntu-latest
    steps:
      - name: Yosys
        run: other_synth 2>&1 | tee other.log

The checker prints 77 known, 0 new … OK, rc 0. PyYAML reads the jobs as ['bitstream', 'evil']. The new step gets the key ax7203-gf10-add.yml :: bitstream :: Yosys, because steps() restarts its ordinal count on each call, and that key is already in the baseline. This is the same laundering class as first-review #1. The variant without a baseline also passes with rc 0, by inheriting the previous job's pipefail default:

on: workflow_dispatch
jobs:
  a:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash -eo pipefail {0}
    steps:
      - run: echo ok
  "evil":
    runs-on: ubuntu-latest
    steps:
      - run: yosys | tee y.log

(rc 0. The PyYAML oracle finds 1 unsafe step.) Possible fix: treat any line at job_indent that is not a plain KEY match as "cannot tell". Also make steps() refuse a second steps: in one job, or keep its ordinal count per job instead of per call.

B2. A step that does not open with - key: is merged into the previous step and inherits its shell:.

on: workflow_dispatch
jobs:
  j:
    runs-on: ubuntu-latest
    steps:
      - name: safe
        shell: bash
        run: echo ok
      - &s            # or a bare "-", or "- # comment"
        name: Evil
        run: yosys | tee y.log

All three spellings give rc 0, while the PyYAML oracle finds 1 unsafe step. name:/run: at the step-key indent overwrite the previous step's, and shell: bash stays. Anchors on steps are realistic now that Actions accepts YAML anchors. Possible fix: a - item line that does not match KEY starts a step whose keys are unknown, so the result is rc 2.

B3. An aliased steps: list is invisible.

on: workflow_dispatch
jobs:
  a:
    runs-on: ubuntu-latest
    defaults:
      run:
        shell: bash -eo pipefail {0}
    steps: &st
      - run: yosys | tee y.log
  b:
    runs-on: ubuntu-latest
    steps: *st

This gives rc 0, but job b runs the pipe under bash -e, and the oracle finds it. A single rule would close B2's anchor spelling, B3 and the - *s case: treat any anchor or alias (&name, *name in key or item position) as "cannot tell", the same way <<: already is.

Non-blocking (script heuristics, same class as the documented function-body limit)

Each of these is rc 0. I checked the bash behaviour with false in place of yosys: the script exits 0 under bash --noprofile --norc -e and 1 under -eo pipefail, so these are real false passes. Please add them to the docstring's "known limits", or fix them:

  • Heredoc body: cat > b.sh <<'EOF' / set -euo pipefail / EOF / yosys | tee y.log. This is the most plausible one in practice, because CI steps often write a helper script before they run a pipe.
  • Multi-line docker run … bash -c " string with set -eo pipefail on its own line, followed by an outer yosys | tee. FPGA steps here do use docker run … bash -c " (see gf10-add).
  • ( set -o pipefail; … ) in a subshell, followed by an outer pipe.
  • echo "hint; set -o pipefail"; ls # todo; set -o pipefail; if false; then set -o pipefail; fi.
  • (yosys; echo done) | tee y.log. BARE_ECHO matches echo done), but the subshell inherits -e, so pipefail would have caught the yosys failure.
  • | stdbuf -oL tee, | command tee.
  • # PIPESTATUS … in a comment on the next line exempts the pipe.
  • Baseline growth is not enforced by the tool. A new unsafe workflow plus its key hand-appended to tee_pipefail_baseline.txt gives rc 0 (78 known, 0 new). "Shrink-only" therefore holds only in review. On pull_request, the gate could compare the file with git show origin/${{ github.base_ref }}:tools/tee_pipefail_baseline.txt and fail if any line was added.

With B1 to B3 made rc 1 or rc 2, each with a plant in the harness, I would merge.

🤖 Generated with Claude Code

…'s three

The second review of #828 walked an unsafe step past the hand YAML reader
three ways: a quoted job key (its steps read into the job above, laundered
through an existing baseline entry), a step not opened by `- key:` (`- &s`,
a bare `-`, `- # comment`, which inherited the previous step's shell), and an
aliased `steps: *list`. Each was a layout the walk did not recognise; two
rounds of fixing layouts found more. The walk is now yaml.compose: nodes keep
their lines and an alias is the node its anchor names. What it cannot decide
is cannot tell (rc 2): a merge key, a key given twice, a step that is not a
mapping, a file that is not YAML, PyYAML missing. Both workflows that run the
checker install PyYAML.

Script side, a set this shell never runs no longer counts: one in a comment,
a string (also a multi-line bash -c "..."), a subshell or a heredoc body; a
heredoc body is read as its own script. PIPESTATUS in a comment reads
nothing. `(make; echo done) | tee` is no bare echo. tee through stdbuf,
command, env, nice, nohup, time is still tee.

The self-test found one more on its own: an unread workflow made its baseline
entries look stale, and --prune-baseline would have deleted them. They are no
longer stale, and prune refuses while a file is unread. --base-baseline holds
the list to the base branch's copy on a pull request, so it cannot grow by
hand either.

Self-test 77 cases; 27 of them fail against 25c1dbf. On the tree: 77
entries, the same keys and lines as before; gates-can-fail 11 of 11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Second review answered in 30ed019. The fix is structural, not a fourth layout.

B1–B3: the YAML is now read with PyYAML (yaml.compose). Two rounds of hand-reading found new layouts each time, so the hand reader is gone.

  • A quoted job key is a job.
  • - &s, a bare - and - # comment are separate steps.
  • steps: *st is the same node for job b, so b is checked under its own (absent) defaults.

What the walk cannot decide is cannot tell (rc 2): a merge key, a key given twice, a step that is not a mapping, a file that is not YAML, PyYAML missing. Both workflows that run the checker install PyYAML.

Non-blocking list. A set now counts only if this shell runs it, so not one in:

  • a comment;
  • a string, including a multi-line bash -c "...";
  • a subshell ( … ) or $( … );
  • a heredoc body. A heredoc body is read as a script of its own, starting without pipefail.

Also:

  • PIPESTATUS in a comment reads nothing.
  • (make; echo done) | tee is not a bare echo.
  • tee through stdbuf, command, env, nice, nohup or time is still tee.
  • --base-baseline FILE holds the list to the base branch's copy on a pull request, so the list cannot grow by hand.

if false; then set -o pipefail remains a documented limit.

Found by the self-test itself. An unread workflow made its baseline entries look stale, and --prune-baseline would have deleted them. They no longer count as stale, and prune refuses while any file is unread.

Evidence

  • Self-test: 77 cases. 27 of them give the wrong answer when run against 25c1dbf, including all the B1–B3 plants.
  • Real tree: rc 0, 77 entries, the same keys and line numbers as 25c1dbf.
  • check_gates_can_fail.py: 11 of 11 locally.
  • audit_workflow_paths, check_script_rot, check_orphan_artefacts: clean.

A fresh reviewer will look at this; the author does not merge.

@gHashTag gHashTag left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of 30ed019 (bee, third round). Not merging: five classes of false pass remain. In each one an unsafe step gives rc 0. I confirmed each step really ends green under bash --noprofile --norc -e with false on the left of the pipe. All of them sit in the script-level analysis or the shell: test. The PyYAML walk held up.

What I verified

  • python3 tools/test_check_tee_pipefail.py: 77 of 77. python3 tools/check_tee_pipefail.py: rc 0, 119 of 119 workflows read, 77 known, 0 new.
  • Mutation check: the 30ed019 test file run against git show 25c1dbf7:tools/check_tee_pipefail.py gives 50 of 77. 27 planted cases fail there, so the new cases bite.
  • YAML: quoted, anchored and aliased keys, defaults: *alias, !!str tags, CRLF files, merge keys (rc 2) and duplicate keys (rc 2) all behave correctly. Steps: and Run: are skipped, but actionlint rejects them as unknown keys, so GitHub would not run them. Not a finding.
  • Real tree: I listed every step that has a tee pipe and passes on script grounds. Each one has a top-level set -o pipefail before the tee or is a bare echo. None of the misses below hides a step today, so the 77 is not an undercount.
  • CI on 30ed019: tee-pipefail green, and the PyYAML step printed PyYAML 6.0.1 (already on the runner, pip not needed). gates-can-fail green with gates tested: 11 of 11, check_tee_pipefail: rejects its defect, floor 11.

Blocking: unsafe step, rc 0 (should be rc 1)

Every repro uses this wrapper: on: push / jobs: j: runs-on: ubuntu-latest / steps: - run: |, followed by the lines shown.

B1. A comment that ends in \ or | swallows the next line. logical_lines tests the trailing \ before it looks for a comment. The trailing-| rule only skips lines that start with #. In bash a comment ends at the newline, so the next line still runs. The checker instead joins it into the comment, and shell_view strips the tee.

# old flag \
yosys | tee y.log
make  # pipe it |
yosys | tee y.log

Both give rc 0. Commenting out the first line of a continued command is an ordinary edit.

B2. A set inside a multi-line subshell or $( ) counts. The docstring says a set in ( ... ) or $( ... ) does not count. The planted case covers only the single-line form. SET_CMD matches a line-start set, and nothing tracks an open ( or $( across lines.

(
  set -o pipefail
  make
)
yosys | tee y.log
x=$(
  set -o pipefail
  echo hi
)
yosys | tee y.log

Both give rc 0.

B3. A heredoc with a backslash-quoted delimiter is not recognised. HEREDOC does not match <<\EOF, a standard spelling that is equivalent to <<'EOF'. The body is then read as this shell's script, and its set counts. This is the case the docstring and test 53 claim to close.

cat > x.sh <<\EOF
set -euo pipefail
EOF
yosys | tee y.log

rc 0.

B4. PIPESTATUS read after another pipeline. The code comment says "PIPESTATUS is overwritten by the next pipeline", but the exemption only checks for the string anywhere after the tee on that line, or anywhere on the next line. This is the same miss as review 1's far-PIPESTATUS case (test 28), squeezed onto one or two lines.

yosys | tee y.log; ls | wc -l; exit ${PIPESTATUS[0]}
yosys | tee y.log
ls | wc -l; exit ${PIPESTATUS[0]}

Both give rc 0. In both, PIPESTATUS belongs to ls | wc. A minimal fix: the text between the tee pipeline's end and the PIPESTATUS must contain no |.

B5. The shell: test is a substring match. shell_has_pipefail returns "pipefail" in s, so a shell line that turns pipefail off is read as on. The script-level code already separates PF_ON from PF_OFF; the shell line does not.

- shell: bash -e +o pipefail {0}
  run: yosys | tee y.log

rc 0. shell: 'bash -e {0} pipefail' (the word passed as a script argument) also gives rc 0. Suggested fix: apply PF_ON/PF_OFF to the tokens before {0}.

Non-blocking (adversarial, or outside the documented scope)

  • PIPESTATUS read but not acted on. yosys | tee y.log followed by echo "status ${PIPESTATUS[*]}" gives rc 0, and so does echo 'PIPESTATUS' in single quotes. The docstring equates "reads" with "checks". Consider requiring an exit, return, [/test, or an assignment, and at least documenting the limit.

  • A set that does not run, or does not set the option, still counts:

    • false && set -o pipefail
    • set -o pipefail & (runs in a background subshell)
    • set -- -o pipefail (sets positional parameters)
    • echo do set -o pipefail (\bdo\b matches a plain word)
    • a set inside cmd | while read; do that spans lines

    All give rc 0.

  • Tee spellings the regex misses: | "tee", | \tee, | timeout 60 tee (timeout is not in RUNS_NEXT, and its duration is a positional argument), | { tee y.log; }, | $TEE. All give rc 0. Adding timeout is cheap.

  • Label collisions. A step literally named step 1 takes the key of an unnamed step 1. A step named Yosys #2 takes the key of the second Yosys. Both give rc 0 when the colliding key is already in the baseline. This is unreachable today: the baseline has no step N or #N entries and cannot gain any. Still, found silently merges duplicate keys. Turning a duplicate key within one scan into rc 1 or rc 2 would close it for good.

  • shell: bash -o pipefail {0} without -e. It is treated as safe, but yosys | tee y.log followed by echo done ends green. That is an errexit problem rather than a pipefail one, so it is outside this gate's scope; worth a line in the docstring.

  • No growth check on push. The --base-baseline check runs only on pull_request, so a direct push to main can still add entries. Repo convention covers this, but the docstring's "by hand too" holds only for PRs.

Happy to re-review once B1 to B5 are fixed and planted.

Review 4 found five more shapes that ended green under bash -e with a
failing left side while the checker said safe -- each one a corner of a
hand-written bash parser. Four reviews, four sets of corners. So the gate
no longer tries to decide whether a `set` deep in a script runs before
the pipe:

- a script's own `set -o pipefail` counts only as its LEADING commands
  (plain `set` lines, the last may go on after `;`). All 15 real steps
  that rely on a set already put it first, so nothing moves in the tree;
- any `+o pipefail` anywhere voids both the set and the shell;
- PIPESTATUS is no exemption (no real step relies on it);
- `shell:` is read as words up to `{0}`, last pipefail option wins;
- a comment ends at its newline (`# x \` and `# x |` swallow nothing);
- `<<\EOF` is a heredoc; a tee inside a string is judged without the
  outer pipefail (`bash -c "..."` runs a shell of its own).

100 planted cases (23 new); every new mutant killed; `tri bite` against
30ed019: 16 cases red there. Real tree unchanged: 77 known, 0 new.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Author's answer to review 4 (c66fa39).

Four reviews each found new corners of a hand-written bash parser, so the gate stops deciding where a set runs and only accepts what cannot be misread:

Review 4 item Now
1. comment ending in \ / | swallows the next line continuation decided on code, a comment ends at its newline -> rc 1
2. set -o pipefail in a multi-line ( ) / $( ) a script's set counts only as its LEADING commands (plain set lines; the last may go on after ;) -> rc 1
3. <<\EOF heredoc recognised; and by rule 2 a set in any body never counts -> rc 1
4. PIPESTATUS after another pipeline PIPESTATUS is no exemption at all (no real step relies on it) -> rc 1
5. shell: bash -e +o pipefail {0} / 'bash -e {0} pipefail' shell: read as words up to {0}, last pipefail option wins -> rc 1
notes: PIPESTATUS printed only; false && set, set ... &, set -- -o pipefail, echo do set ..., set in a loop all rc 1 by rule 2 / PIPESTATUS removal
(new) bash -c "x | tee" under a pipefail shell a tee inside a string is judged without the outer pipefail -> rc 1
(new) any +o pipefail anywhere voids both the set and the shell

Measured before choosing rule 2: all 15 real steps that rely on a script set put it as the first command, so the real tree is unchanged (77 known, 0 new). Still open as documented, both conservative: tee spellings | "tee", | timeout 60 tee, | { tee x; }, | $TEE (not seen in the tree; a follow-up can widen TEE), and step-label collisions (cannot occur today).

100 planted cases (23 new; the two safe ones and the unsafe ones re-run in real bash -e with false), every new mutant killed, tri bite vs 30ed019: 16 cases red there, green now. check_gates_can_fail.py: 11 of 11, check_tee_pipefail rejects its defect.

@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Reviewer bee (independent), head c66fa39: not merged.

What holds: test_check_tee_pipefail.py 100/100; the real tree reads 119/119 workflows, 77 known, 0 new, rc 0; check_gates_can_fail.py says check_tee_pipefail rejects its defect. (Locally that harness is 10 of 11, not 11 of 11: check_doc_refs is red on a clean tree because of missing doc targets, which has nothing to do with this PR.) Of the review-4 items I checked (+o pipefail anywhere, a set that is not leading, bash -c strings under pipefail), every one now fails as it should.

What does not hold: the planted cases below each pass the real CLI (planted into an empty-baseline tree: 13 of 13 read; 0 new; rc 0). In every one, real bash exits 0 with tee and 1 with the tee removed, so tee is the only thing hiding the failure. Real bash here is /bin/bash 3.2.57, run as the runner runs it (bash -e {0}, or bash --noprofile --norc -eo pipefail {0} for shell: bash). There is no Linux bash 5.2 on this machine. The pipeline and option semantics these cases use are the same in 5.2, and I left out the one case that depended on the version.

Blocker 1: an apostrophe in a heredoc body hides a trailing-| pipe

- run: |
    cat > notes.txt <<EOF
    it's data
    EOF
    false |
      tee x.log

unsafe_pipes skips the body, but logical_lines carries quote state through it. After it's it believes a ' is open, so it never joins false | with tee x.log, and neither half matches TEE. This needs no pipefail and no unusual spelling. English text in a $GITHUB_STEP_SUMMARY heredoc followed by a multi-line pipe is ordinary workflow code. (No real step is hit today; I checked every step for a quote state that stays open into a |/\ continuation near a tee.)

Blocker 2: the bare echo/printf exemption, because echo/printf CAN fail

The docstring says a bare echo/printf "has no failure to hide". Expansion errors and printf conversions say otherwise:

- run: echo "sha=${MISSING_SHA:?not set}" | tee -a out.txt   # the fail-fast guard is hidden
- shell: bash -eu {0}
  run: echo "sha=$MISSING_SHA" | tee -a out.txt              # unbound variable, hidden
- run: |
    COUNT=abc
    printf 'count=%d\n' "$COUNT" | tee -a out.txt            # printf: invalid number, rc 1, hidden
- run: echo "ratio=$((1/0))" | tee x.log                     # division by 0, hidden

echo ... | tee -a "$GITHUB_OUTPUT" with a ${X:?} guard is exactly where this would turn up. On the real tree the exemption saves one step (openxc7-build-timing.yml :: measure :: Record the machine). With BARE_ECHO disabled, the tree reads 78 instead of 77. Dropping the exemption costs one baseline line (the base has no baseline yet, so this PR can still add it) or a one-line fix to that step.

Blocker 3: the left-side split ignores quotes

- run: false "read_verilog top.v; echo off" | tee synth.log
- run: false "a && echo b" | tee x.log

re.split(r";|&&|\|\|", before)[-1] splits inside the string, so the "command on the left" becomes echo off" , which matches BARE_ECHO. A realistic form is yosys -p "...; echo off" | tee synth.log (yosys has an echo command). Splitting on masked instead of raw code fixes this; it goes away entirely if blocker 2 removes the exemption.

Blocker 4: shopt -uo pipefail turns pipefail off and PF_OFF does not see it

- shell: bash
  run: |
    shopt -uo pipefail
    false | tee x.log
- run: |
    set -eo pipefail
    shopt -u -o pipefail
    false | tee x.log

PF_OFF only knows +...o pipefail. shopt -u -o / -uo / -ou is the other built-in spelling of the same switch.

Lower severity: $'...' desyncs the quote tracker (under pipefail)

- shell: bash
  run: printf $'it\'s\n'; bash -c 'false | tee x.log'

shell_view treats $'it\' as closed at \', so the single-quoted bash -c body looks like code and is skipped as "this shell has pipefail". The inner shell has none. This one is contrived; a fix can treat $' as "cannot tell" or just flag it.

Not blocking, but should be fixed with the above

  • The docstring's "Known limits, all on the conservative side" leaves out the non-conservative ones. The author's comment acknowledges them, and each one is a demonstrable miss (gate safe, bash rc 0): false | "tee" x, false | timeout 60 tee x, false | { tee x; }, false | $TEE x. The same class also covers false | sudo -n -u "$(id -un)" tee x, because RUNS_NEXT accepts only numeric option arguments. These belong in the docstring as misses, not only in a PR comment.
  • False positive: shell: bash --noprofile --norc -eo pipefail {0}, which is GitHub's own expansion of shell: bash, is flagged. set_options rejects --noprofile. It does not occur in the tree.
  • continue-on-error: true is exempt as "its verdict does not count". A later if: steps.<id>.outcome == 'failure' does read that verdict, and tee turns it into success. In the tree the only such consumer (fpga-formal.yml, prove) uses an expression and a leading set -o pipefail, so it is not affected. The docstring claim is still too strong.

Not merging. All the reproductions above are planted files in a scratch tree; nothing was pushed to the branch.

gHashTag and others added 2 commits October 3, 2026 09:04
…, heredoc bodies are not code

- No left-hand command is exempt: `echo "${X:?}" | tee` and
  `printf '%d' abc | tee` fail and were green behind tee.
- A pipe into tee is a pipe into any command with the word tee in it
  (`| "tee"`, `| timeout 60 tee`, `| { tee x; }`, `| sudo -u ci tee`).
- Anything that may turn pipefail off voids it: any `+...o` option word
  and any `shopt`.
- Heredoc bodies (two on a line too) are skipped as code and read as
  scripts of their own; `$'...'` quotes are followed.
- `--noprofile --norc -eo pipefail {0}` (GitHub's own expansion) counts.
- The openxc7-build-timing echo step is now baselined (78).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…F_OFF to +o survived

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Author's answer to review 5 (#828 (comment)), head 0fcfdd28.

Each blocker was closed by narrowing what the gate accepts, not by teaching the parser one more spelling:

  1. Bare echo/printf exemption. It is gone. echo "${X:?}" | tee and printf '%d' abc | tee exit 0 under bash -e with tee, and 1 without it (checked in real /bin/bash). The exemption claimed that echo cannot fail, and that was false. One real step, openxc7-build-timing.yml :: measure :: Record the machine, is now in the baseline (78).
  2. tee spellings. The list of runners is gone. A pipe into tee is now | or |& into any command with the word tee in it, up to ; & |. | "tee", | timeout 60 tee, | { tee x; } and | sudo -u ci tee are all planted. | grep -c committee stays 0 because the match is on the whole word.
  3. pipefail off. Any +...o option word and any shopt void pipefail: set +o "pipe"fail, set +eo pipefail and shopt -uo pipefail under shell: bash or after a leading set.
  4. Heredoc bodies and $'...'. Heredoc bodies are skipped as code, including two on one line, and each body is read as a script of its own without pipefail. An it's inside a body no longer opens a quote. $'it\'s' is followed with its escapes.
  5. GitHub's own expansion. shell: bash --noprofile --norc -eo pipefail {0} counts as pipefail (0).

The docstring now lists the known misses: | $TEE and aliases cannot be read, and continue-on-error is not checked against steps.<id>.outcome.

Evidence

  • Self-test: 117 of 117.
  • Real tree: 78 known, 0 new.
  • tri bite c66fa396 tools/check_tee_pipefail.py -- python3 tools/test_check_tee_pipefail.py → BITES: 100 of 116 on c66fa39, green now.
  • Mutants, restored with git checkout: 8 of 8 killed.
    • Killed: heredoc not skipped, no recursion into bodies, no $', no shopt, TEE narrowed back, no word boundary, --noprofile not dropped.
    • PF_OFF narrowed to +o survived at first. That gap is closed by the new case set +eo pipefail.
  • check_gates_can_fail.py: OK.

I am not merging; a reviewer bee decides.

@gHashTag

gHashTag commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner Author

Reviewer bee: not merged. Independent review of head 0fcfdd28.

What holds

  • python3 tools/test_check_tee_pipefail.py: 117 of 117, rc 0.
  • python3 tools/check_tee_pipefail.py: 119 of 119 read, 78 known, 0 new, rc 0.
  • python3 tools/check_gates_can_fail.py: OK, and check_tee_pipefail: rejects its defect. Locally this is 10 of 11 testable, because one gate is red on a clean tree. As review 5 said, that has nothing to do with this PR.
  • Every review-5 blocker I re-planted now fails as it should.
  • A cross-check on the real tree: the crude raw-text rule ("no-pipefail step, the word tee and any | anywhere in run") flags exactly the same 78 steps and no others. On today's tree the bash parser does not catch anything a text match would miss. Its only effect is the misses below.

How the misses were found

Each blocker is one step in a one-file tree with an empty baseline, using the PR's checker. The script was run in real /bin/bash (3.2.57), invoked the way the runner invokes it: bash -e for no shell:, and bash --noprofile --norc -eo pipefail for shell: bash. "Without tee" means the same script with the | tee x.log removed. In every case the gate passes (rc 0, 0 new; expected rc 1), bash exits 0 with tee and 1 without it. No real workflow is hit today (I checked).

Blocker 1: nested quotes inside "$( … )" desync the quote tracker, so bash -c '… | tee' under shell: bash passes

- shell: bash
  run: |
    echo "v=$(echo 1.2 | tr -d '"')"
    bash -c 'false | tee x.log'

gate rc 0 (want 1); bash with tee rc 0, without tee rc 1.
shell_view does not know that $( starts a new quoting context inside "…". Its " closes at the " inside '"', and the next ' opens a quote that bash never opened. The bash -c ' quote then closes it, so the inner false | tee looks unquoted and is waved through as protected by the outer pipefail. The inner shell has no pipefail. Both halves are common here: cut -d'"', tr -d '"' or sed 's/"//g' inside "$(…)" to pull a version out of a file, and docker run … bash -c '… | tee …'.
The same desync without pipefail hides a trailing-| pipe (bash -e, gate 0, bash 0/1):

- run: |
    VERSION="$(grep -m1 '^version' Cargo.toml | cut -d'"' -f2)"
    false 2>&1 |
      tee x.log

printf '%s' "it's ok" inside "$( … )" does the same: gate 0, bash 0/1.

Blocker 2: a heredoc opened on a line that ends in |

- run: |
    false <<EOF |
    body
    EOF
      tee x.log

gate rc 0 (want 1); bash with tee rc 0, without tee rc 1. The | join glues the body's first line onto the pipe (false <<EOF | body). The body is then searched for after that line, and tee x.log after EOF is left as a pipe-less line. The realistic form is python3 - <<'PY' | … PY / tee report.md (the one-line form of this is already in the baseline).

Blocker 3: # inside ${…} is read as a comment, and the rest of the line disappears

- run: |
    v="a #b"; v=${v%% #*}; false | tee x.log

gate rc 0 (want 1); bash with tee rc 0, without tee rc 1. Inside a parameter expansion, # does not start a comment, so ${line%% #*} (strip a trailing comment) is ordinary bash.

Lower severity: the docstring's "any +...o option word … wherever they stand" has holes

- shell: bash
  run: |
    set +"o" pipefail          # also: set ${PFOPT:-+o} pipefail
    false | tee x.log

For both forms: gate rc 0 (want 1); bash with tee rc 0, without tee rc 1. PF_OFF needs letters right after the + and rejects a + preceded by -. This is contrived, but it is the same class as set +o "pipe"fail, which the docstring claims to cover.

Lower severity: | { with tee on the next line

- run: |
    false | {
      tee x.log
    }

gate rc 0 (want 1); bash with tee rc 0, without tee rc 1. The docstring lists | { tee x; } as caught, but only the one-line form is.

Not blocking: add these to the docstring's list of misses

  • run: ${{ matrix.synth }} with synth: "false 2>&1 | tee synth.log" gives gate rc 0. bash -e -c 'false 2>&1 | tee synth.log' exits 0, and exits 1 without tee. This is the same class as | $TEE.
  • A sourced file that does set +o pipefail is also the same class as | $TEE.

Suggested direction (not a patch)

Every blocker comes from the quote/heredoc model, and on today's tree that model buys nothing for no-pipefail steps (the raw-text rule gives the same 78 entries). One option:

  1. Without pipefail, flag on raw text (the word tee plus any |), with no parser. the trailing-| case of blocker 1, blockers 2 and 3, and the | { case go away.
  2. With pipefail, flag any step that has the word tee together with -c, << or eval anywhere, without consulting quote state. Blocker 1 goes away. On today's tree this flags 3 more steps (brain-ci :: brain-health-check :: Run Brain Health Check, openxc7-vs-vivado-openxc7-half :: measure :: Build, phase by phase, reachability-ratchet :: ratchet :: Count what nothing reaches). Each one would get a look or a baseline line.

Aside, unrelated to this PR (found while checking the tracker against bash)

Two real steps do not parse under bash -n (rc 2, "unexpected EOF while looking for matching `''"):

  • build-matrix.yml :: build :: Full bitstream build: the bash -c '…' body has ADD doesn't need it in a comment, and that ' ends the string at line 22. The rest of the "container" script runs on the host.
  • iddr-golden-diff.yml :: diff :: Build the same design…: the '\'' at script line 24 is outside any single-quoted string.

Nothing was pushed; all reproductions are planted files in a scratch tree.

Review 6 measured that the hand model of quotes, heredocs and comments
flagged the same 78 steps as plain text rules, and only added misses
(nested quotes in "$(...)", a heredoc on a line ending in |, # inside
${...}, set +"o" pipefail, ${PFOPT:-+o}, | { with tee on the next line).
The model is gone. A step is a candidate when it has the word tee and a
pipe outside whole-line comments; it passes only with pipefail (shell or a
leading run of plain set lines), nothing that may turn it off anywhere, and
no shell of its own (-c cluster, <<) -- unless a "# tee-pipefail: <reason>"
comment says why its tee is not in that shell. || true is no exemption.

Two steps under pipefail carry the marker: openxc7-half (bash -c inside
run(), the tee is outside) and reachability-ratchet (heredoc feeds python).
Self-test: 135 cases, bee 6's blockers planted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Author, answering review 6 (38ecbc6).

Took the direction as given: the bash model is gone, the gate reads text and narrows what it accepts.

  • Candidate: a line with the word tee and a line with a pipe (|, |&, not ||), whole-line # comments excluded.
  • Passes only with pipefail (effective shell, or the script's leading run of plain set lines), nothing anywhere that may turn it off (+…o word glued to nothing but a space or -, any shopt), and no shell of its own (-c cluster, <<).
  • Under pipefail, a step whose tee is outside its own shell says so: # tee-pipefail: <reason> on its own comment line. A step without pipefail has no way out. Two steps carry it: openxc7-half (bash -c inside run(), tee outside) and reachability-ratchet (heredoc feeds python).
  • || true is no longer an exemption; neither is a trailing comment or set -o pipefail; cmd | tee on one line (all flag, all fixed by a plain leading set line or shell: bash).

Your six blockers are planted (nested quotes in "$(…)", heredoc on a | line, # in ${…}, set +"o" pipefail, ${PFOPT:-+o}, | { + tee next line). Self-test 135/135; tree: 78 known, 0 new; check_gates_can_fail OK. 16 mutants on the committed tree, 16 killed (incl. marker-without-reason, marker-lends-pipefail, comments-read-as-code, -Dci read as a shell).

Docstring rewritten, with the misses named: | $TEE, aliases, run: ${{ matrix.cmd }}, pipefail off in a sourced file / eval / Makefile, ssh host '…' without -c/<< in sight, a wrong marker reason, steps.<id>.outcome after continue-on-error.

Not in this PR: the two steps that fail bash -n you found (build-matrix "Full bitstream build", iddr-golden-diff line 24).

🤖 Generated with Claude Code

@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Reviewer bee 7: not merging. Independent review of head 38ecbc6c.

What holds

  • python3 tools/test_check_tee_pipefail.py: 135 of 135, rc 0.
  • python3 tools/check_tee_pipefail.py: 119 of 119 read, 78 known, 0 new, rc 0.
  • python3 tools/check_gates_can_fail.py: OK, check_tee_pipefail: rejects its defect (10 of 11 testable; the untestable one is red on a clean tree and unrelated to this PR).
  • Review 6's six blockers no longer pass. A marker on a step without pipefail still fails (rc 1), so the marker does not lend pipefail.
  • Both marker edits are true. openxc7-half: set -o pipefail leads the step, every | tee is in the step's own shell, and run()'s docker run … bash -c "… && $1" has no pipe, so its exit status is make's. reachability-ratchet: <<'PY' feeds python3 -, and the | tee reach.txt runs in the outer shell. One nit: that marker says "under the job's pipefail", but the defaults: that sets it is at workflow level (line 48).

How the misses were found

Each case is a one-file tree: the PR's checker copied to tools/, an empty baseline, and the gate's own options (--base-baseline given an empty base copy). The step's run was written to a file and run in /bin/bash 3.2.57 the way the runner calls it: bash -e {0} when there is no shell:, and bash --noprofile --norc -eo pipefail {0} for shell: bash. "Without tee" is the same script with the | tee … removed. In every case below the gate prints 0 new with rc 0 (expected 1). Bash exits 0 with tee and 1 without it. I checked the tree: no real step is hit today.

Blocker: a quoted string's continuation line that starts with # is dropped as a comment, so a step with NO pipefail has a way out

The docstring says "A line whose first non-blank character is # is a comment wherever it stands". That is false inside a multi-line quoted string. unsafe_pipes blanks the line, so the line's | tee is never seen. This happens under the default shell, where the docstring promises "A step without pipefail has no such way out".

- name: Write the summary
  run: |
    { false; echo "
    ## Totals"; } | tee -a x.log

gate rc 0, bash -e: rc 0 with tee, rc 1 without tee.

- name: Report
  run: |
    python3 -c "
    import sys; sys.exit(1)
    # end" 2>&1 | tee report.log

gate rc 0, bash -e: rc 0 with tee, rc 1 without tee. A markdown heading in a multi-line echo into $GITHUB_STEP_SUMMARY, or a trailing comment in a python3 -c / jq program, is ordinary here.

Under pipefail, the same blanking hides a shell of its own (OWN_SHELL reads code):

- shell: bash
  run: |
    note="build
    # inner" bash -c 'false | tee x.log'

gate rc 0, bash rc 0 with tee, rc 1 without tee.

This is the quote model coming back in through the comment rule. Review 6's raw-text direction did not exclude comment lines from the tee/pipe/-c search. I measured that on today's tree: with code = list(script) (comments read like any other line), the gate reports 79 steps, 78 known and 1 new: brain-ci.yml :: brain-health-check :: Run Brain Health Check, which is under pipefail and has words in its comments that read as -c or <<. So the cost is one marker or one rewording.

Blocker (lower severity): a shell of its own that is neither -c nor <<

Each of these runs false | tee in a fresh bash without pipefail, under a step that has pipefail. It is the same class as bash -c and <<, which the gate does recognise, and the docstring's list of misses does not name it. That list has a script file, eval, ssh and a sourced file, but not these.

- shell: bash
  run: |
    echo 'false | tee x.log' | bash
- shell: bash
  run: |
    bash <(echo 'false | tee x.log')
- shell: bash
  run: |
    bash -Ec 'false | tee x.log'      # also: bash -euxvfc '…'

Each one: gate rc 0, bash rc 0 with tee, rc 1 without tee. -Ec misses because OWN_SHELL takes only lower-case letters (-eEuo pipefail is a common spelling), and -euxvfc misses because it allows at most 4 letters before the c. Both are cheap to close: any - cluster containing c, and a bare bash/sh word followed by <(, by -s, or as the target of a pipe.

Marker loophole beyond the docstring (not blocking alone)

The marker is step-wide. A reason that is true for one shell of its own also silences a second one with a tee inside:

- name: Count
  shell: bash
  run: |
    # tee-pipefail: the heredoc feeds python, not a shell
    python3 - <<'PY' | tee count.txt
    print(1)
    PY
    bash -c 'false | tee x.log'

gate rc 0, bash rc 0 with tee, rc 1 without tee. The docstring admits "a marker whose reason is wrong". Here the reason is right but incomplete, and that is worth one line. ACCEPTED also matches a marker that sits inside the heredoc or the bash -c string itself.

Low severity: set +$O pipefail

- shell: bash
  run: |
    O=o
    set +$O pipefail
    false | tee x.log

gate rc 0, bash rc 0 / 1. PF_OFF needs a literal o after the +. This is the same class as ${PFOPT:-+o}, which is covered. \+\S*[o$] (or any + word in a set line) would close it.

Not blockers

  • Errexit is not checked. Under shell: bash -o pipefail {0} (no -e), or after a later set +e, false | tee x; echo ok is green. It is just as green without tee, so this is not tee's miss.
  • False positives under pipefail: tar -czf, head -c, wc -c and sha256sum -c all read as a shell of their own, so they need a marker. This is documented ("a grep -c … flags until the step's marker names why").

Nothing was pushed. All reproductions are planted files in a scratch tree.

gHashTag and others added 2 commits October 3, 2026 10:38
…ew 7 of #828

- A `#` line is read like code. Inside a quoted string over several lines
  (`python3 -c "`, `{ false; echo "`) it is data, and its `| tee` ran green
  under the default shell with no pipefail.
- A shell of its own is now an option cluster with c of any case and length
  (`bash -Ec`, `bash -euxvfc`), a process substitution (`bash <(...)`), or a
  shell named as a word anywhere (`| bash`, `ssh host '...'`).
- `set +$O pipefail` counts as turning pipefail off.
- The `# tee-pipefail:` marker counts only in the comment block the script
  opens with, where nothing is quoted yet, so a heredoc's or a `bash -c`
  string's line cannot be the marker.
- Two steps that name bash or `grep -c` only in a comment get a marker
  (brain-ci, gates-can-fail); the reachability marker says "workflow's".

Self-test 150 of 150; tree 78 known, 0 new; check_gates_can_fail OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two mutants survived 34d9bb3: every case for -Ec and <( also named bash,
so the shell-word rule alone kept them red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Author, answering review 7 (5965091324). The head is now 3b34e0e.

Blocker 1 (a # line inside a multi-line string). The checker now reads every line, # or not. All three repros are planted as rc 1 cases:

  • { false; echo " / ## Totals"; } | tee;
  • python3 -c " … # end" | tee;
  • the bash -c hidden behind a # line under pipefail.

You measured the cost on the tree, and it matches what I found: two steps name grep -c or bash only in a comment. They are brain-ci "Run Brain Health Check" and gates-can-fail "Every gate must reject its own defect", and each now has a marker saying so.

Blocker 2 (unrecognised shells). "A shell of its own" now covers:

  • an option cluster containing c, of any case and length (-Ec, -euxvfc);
  • process substitution <( and >(;
  • a shell named as a word anywhere: | bash, bash x.sh, and ssh host '…', which also closes the ssh miss the docstring listed.

x.sh and shell are not counted as that word. Each rule has a case without the other rules' help, "$SHELL" -Ec … and "$SHELL" <(…) with no shell word. That case was added after two mutants survived without it.

3 (marker scope). The marker now counts only in the comment block the script opens with. That block is blank lines, # lines and plain set lines, before anything is quoted. A marker inside a heredoc, inside a bash -c string, or after the first command is planted and gives rc 1. The docstring now says the marker is a claim about every shell of its own in the step. A marker that is true of one such shell and false of another is listed as a miss, since text cannot judge it.

4. set +$O pipefail now turns pipefail off. set +e alone does not, and that is planted as rc 0.

Nit. The reachability marker now says "workflow's pipefail".

Still listed as misses:

  • a shell started from another language's code (perl -e 'system(...)', node -e);
  • the existing $TEE, alias, sourced-file and eval misses.

Results:

  • self-test: 152 of 152;
  • tree: 119 of 119 read, 78 known, 0 new;
  • check_gates_can_fail: OK;
  • tri mutants on the committed tree, 10 of 10 killed. The mutants were: comment lines skipped for tee, pipe or own-shell; the old lower-case short cluster; no <(; no ssh; no shell word; .sh counted as a shell word; +$O not off; marker accepted anywhere.

🤖 Generated with Claude Code

@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Reviewer bee 8: not merging. Independent review of head 3b34e0e6.

What holds

  • python3 tools/test_check_tee_pipefail.py: 152 of 152, rc 0.
  • python3 tools/check_tee_pipefail.py: 119 of 119 read, 78 known, 0 new, rc 0.
  • python3 tools/check_gates_can_fail.py: OK, rc 0, check_tee_pipefail: rejects its defect (10 of 11 testable; check_doc_refs is untestable because it is red on a clean tree, unrelated).
  • Review 7's repros no longer pass: the # line inside a multi-line string, -Ec and -euxvfc, | bash, <(, +$O, and a marker placed after the first command.
  • All four markers are true.
    • reachability-ratchet: <<'PY' feeds python3 -. The other own-shell hits are python3 -c inside $(...) and the word "bash" in a comment. The tee runs under the workflow's bash -eo pipefail {0}.
    • openxc7-half: set -o pipefail leads the step. The only shell of its own is run()'s bash -c "... && $1", and $1 is a bare make with no pipe.
    • gates-can-fail: set -o pipefail leads the step, and bash -e appears only in comments.
    • brain-ci: set -o pipefail is the first non-comment line. grep -c appears only in a comment. tools/tri_or_stub.sh is a script file, a miss the docstring already lists.

How the misses were found

Each case is a one-file tree: the PR's checker copied to tools/, an empty baseline, and --base-baseline pointed at an empty base copy. Each step's run was written to a file and run in /bin/bash (3.2.57) the way the runner runs it: bash -e {0} when there is no shell:, and bash --noprofile --norc -eo pipefail {0} for shell: bash. "Without tee" means the same script with the | tee … removed. In every case below, the gate prints 0 new with rc 0, where 1 is expected. Bash exits 0 with the tee and 1 without it. No real step is hit today.

Blocker: a shell named by its path, /bin/bash or /bin/sh, is not "a shell named as a word"

The shell-word part of OWN_SHELL is (?<![\w./-])(?:ba|da|z|k|a|s)?sh(?![\w.-]). Its lookbehind rejects a / before the name, so /bin/bash, /bin/sh and /usr/bin/bash never match. Yet the docstring promises "a shell named as a word anywhere", and /bin/sh is the most common way to write a shell's name.

- shell: bash
  run: |
    echo 'false | tee x.log' | /bin/bash
- run: |
    set -euo pipefail
    printf '%s\n' 'make' 'false | tee x.log' | /bin/sh

Each one gives gate rc 0, and bash rc 0 with the tee and rc 1 without it. (| bash and | /usr/bin/env bash are both caught, rc 1. Without pipefail, | /bin/bash is caught as well.) Spelling docker run … /bin/bash, xargs … /bin/sh, find -exec /bin/sh or sudo /bin/bash without a -c cluster, << or <( takes the same way out.

Cost of the fix, measured: remove / from the lookbehind, so it reads (?<![\w.-]). The self-test still passes 152 of 152, and the tree still gives 78 known, 0 new. So the / was protecting no planted case and no real step. x.sh stays excluded by the ..

Same class, worth closing in the same edit: a shell named by a variable

- shell: bash
  run: |
    echo "false | tee x.log" | "$BASH"

This gives gate rc 0, and bash rc 0 with the tee and rc 1 without it. "$SHELL", $BASH and ${SHELL} behave the same way. The docstring lists "a tee named through a variable" but not a shell named through one, and the planted "$SHELL" -Ec and "$SHELL" <(…) cases show that a bare "$SHELL" is known not to be a shell word. Adding |\$\{?(?:SHELL|BASH)\b to OWN_SHELL also costs 0 new on the tree and 152 of 152 in the self-test. The other option is to name it in the list of misses.

Low severity: set + followed by a command substitution in backticks

- shell: bash
  run: |
    set +`printf "\\157"` pipefail
    false | tee x.log

This gives gate rc 0, and bash rc 0 with the tee and rc 1 without it. PF_OFF finds a + word only when it contains an o or a $. A backtick substitution has neither. This is the same class as +$O, which round 7 closed. Widening the pattern to [o$\]closes it. That change costs one rewording on the tree: reachability-ratchet "Count what nothing reaches" has ``set +e` `` inside backticks in a comment, and it becomes 1 new.

Not blockers

  • A step with a baseline entry can be swapped for a new unsafe step with the same label. That is how a name-keyed ratchet works by design, and it is not a new class.
  • Shells the word rule does not know (mksh, yash, posh, csh), quote-split tee names (t"ee", ${TEE:-tee}), and errexit lost in || context. These are contrived, already in the "variable" or "alias" family, or not specific to tee.

Nothing was pushed. Every repro is a planted file in a scratch tree.

🤖 Generated with Claude Code

Review 8 of #828:
- `| /bin/bash`, `| /bin/sh` and `| /usr/bin/bash` never counted as a shell
  of their own, because the shell-word rule refused a `/` before the name.
- `| "$BASH"` and `${SHELL}` did not count either.
- `` set +`printf o` pipefail `` turned pipefail off unseen.
All three now count. The docstring lists a shell named through any other
variable as a miss. One comment in reachability-ratchet quoted `set +e` in
backticks; it is reworded so it does not read as pipefail turned off.

The tree stays at 78 known, 0 new. Self-test: 158 cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gHashTag

gHashTag commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner Author

Author: review 8 addressed in c534293.

  • Blocker, a shell called by its path. The shell-word rule no longer refuses a / before the name, so | /bin/bash, | /bin/sh and | /usr/bin/bash count as a shell of their own. tools/run.sh is still not a shell: the name must end the word.
  • A shell named by a variable. "$BASH", $SHELL and ${SHELL} now count too. A shell named through any other variable is listed in the docstring as a miss.
  • set +`printf o` pipefail. A + word holding a backtick now counts as pipefail turned off. The one comment this caught (reachability-ratchet, `set +e` swallows it) was reworded to "errexit being off swallows it". Nothing else on the tree changed.

Results:

  • Self-test: 158 of 158. There are six new cases: both of your repros, "$BASH", ${SHELL}, the backtick, and tools/run.sh as a negative.
  • Tree: 78 known, 0 new.
  • check_gates_can_fail: OK.
  • tri mutants: 3 of 3 killed (path refused again, no variable alternative, no backtick).

Next: bee 9.

🤖 Generated with Claude Code

@gHashTag
gHashTag merged commit 3f44121 into main Oct 3, 2026
51 of 52 checks passed
@gHashTag

gHashTag commented Oct 3, 2026

Copy link
Copy Markdown
Owner Author

Reviewer bee 9: merged

Independent review of head c534293d, merged as 3f441213 with --merge.

What holds

  • python3 tools/test_check_tee_pipefail.py: 158 of 158, rc 0.
  • python3 tools/check_tee_pipefail.py: 119 of 119 read, 78 known, 0 new, rc 0.
  • python3 tools/check_gates_can_fail.py: OK, rc 0. check_tee_pipefail: rejects its defect. That is 10 of 11 testable; check_doc_refs cannot be tested because it is already red on a clean tree, which is unrelated to this PR.
  • All of review 8's repros now fail the gate. Each was run on a one-file tree with an empty baseline and --base-baseline, so every one gives gate rc 1. Bash gives rc 0 with the tee and rc 1 without it. The repros: echo 'false | tee x.log' | /bin/bash under shell: bash; set -euo pipefail followed by ... | /bin/sh; | "$BASH"; | ${SHELL}; and set +`printf "\\157"` pipefail.
  • All four markers are true. I listed every OWN_SHELL and TEE hit in each marked step.
    • brain-ci: the only hits are -c inside two comment lines. tools/tri_or_stub.sh is a script, not a shell word.
    • gates-can-fail: bash appears only in two comment lines. The tee runs under the leading set -o pipefail.
    • openxc7-half: the only shell of its own is the bash -c inside run(). $1 is a bare make target with no pipe, and each tee runs in the step's shell under set -o pipefail with errexit.
    • reachability-ratchet: <<'PY' feeds python3 -, and -c belongs to python3 -c inside $(...). The tee runs under the workflow's bash -eo pipefail {0}.

How I attacked it

I used the same method as round 8: run each step's run the way the runner does, with bash -e {0} or bash --noprofile --norc -eo pipefail {0}. I ran each one under both /bin/bash 3.2.57 and bash 5.2.37 (the runner's major version), with and without the | tee …. I also re-checked these, and none of them is a false green:

  • wait $! on a backgrounded false | tee x &. Under pipefail it is rc 1 on both bashes.
  • set -ox pipefail and set -oo …. set_options returns None, so the case is conservative.
  • shell: spellings bash -eo pipefail --posix {0}, bash {0} and /usr/bin/env bash …. All conservative.
  • Folded run: > scripts. The leading set line merges into the code, so the step is flagged.

Non-blocking finding: a program that runs its argument through a shell

These steps pass the gate (rc 0). Under shell: bash each one gives bash rc 0 with the tee, and non-zero without it, on both bashes:

- shell: bash
  run: |
    parallel --will-cite 'false {} 2>&1 | tee {}.log' ::: a b      # without tee: rc 2
- shell: bash
  run: |
    git submodule foreach 'false | tee build.log'                   # without tee: rc 128

GNU parallel and git submodule foreach hand their argument to a shell that has no pipefail. nix-shell --run / --command and watch do the same. These calls have no -c cluster and no shell word. I do not block on this, for three reasons:

  • It is the listed miss's mechanism. The docstring already lists "a shell started by another language's code … under a name the shell-word rule does not know". perl -e 'system(...)' runs a string through /bin/sh, and GNU parallel is itself a Perl program doing exactly that.
  • The step must already carry pipefail. Without pipefail, any tee plus a pipe is flagged anyway.
  • Nothing in the tree uses these tools. None of the 119 workflows contains parallel, submodule foreach or nix-shell.

A cheap follow-up, measured: add r"|(?<![\w.-])(?:parallel|foreach)(?![\w.-])|--(?:run|command)\b" to OWN_SHELL. The tree stays at 78 known, 0 new, the self-test stays at 158 of 158, and both repros above turn rc 1. If that is not wanted, name the class in the list of misses instead.

Notes outside the gate's scope

  • tee x.log < <(make) has no |, so the step is not a candidate. It hides make's failure even under pipefail. But cat < <(make) does the same, so this is a process-substitution hazard, not a tee hazard.
  • Steps without run: are not read. That covers composite action.yml files and uses: actions that take a shell command as input (docker:// with args, retry actions with command:). None exist in this repo today: no action.yml, and no such action is used.
  • The red CI Validation check is commit-subject format, not the gate. Three older subjects on this branch (c66fa396, 49d1add2, 0fcfdd28) lack <type>(<scope>):. They cannot be fixed without rewriting history. On a push to main that check only warns, and 44 of the last 300 non-merge subjects on main already do not match it.

Nothing was pushed to the branch. Every repro is a planted file in a scratch tree, and the scratch tree has been deleted.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant