@@ -4,7 +4,7 @@ defmodule WorkflowsTest do
44 @ moduledoc """
55 The continuous integration definitions are code, and they are checked like code.
66
7- Three properties. Every workflow and the Dependabot configuration parse as YAML, so a malformed
7+ Four properties. Every workflow and the Dependabot configuration parse as YAML, so a malformed
88 one fails here rather than silently not running: GitHub reports a broken workflow file on its
99 own page and not on the pull request, which is the wrong place for it to be discovered. Every
1010 third-party action is pinned to a full commit hash rather than a tag, because a tag can be moved
@@ -13,6 +13,8 @@ defmodule WorkflowsTest do
1313 block granting no more than read, so that a job wanting to write has to say so itself: Scorecard's
1414 `Token-Permissions` check, which this repository scored **zero** on until 2026-09-23 because two
1515 workflows declared nothing at all and ran with whatever the repository default happened to be.
16+ And no step pipes the test suite or the gate into another command without `pipefail`, because the
17+ runner's default shell takes the last command's exit, so a red suite piped into `grep` passes.
1618
1719 The third property is the one worth a sentence about why a test rather than a habit. A top-level
1820 write is granted to every job the workflow will ever have, including the ones nobody has written
@@ -83,6 +85,47 @@ defmodule WorkflowsTest do
8385 end
8486 end
8587
88+ # Added after slice 138 (its NOTES, follow-up): the fips job's coverage step ran
89+ # `mix test --cover | grep ...` under the runner's default `bash -e {0}`, which has no pipefail,
90+ # so the step's exit was grep's. On job 118381934625 the suite inside it was 1388/1389 and the
91+ # step and the job passed, with the failing test's name filtered out of the log. A step that runs
92+ # the suite or the gate must fail when they do.
93+ test "no step pipes the test suite or the gate into another command without pipefail" do
94+ offenders =
95+ for file <- yaml_files ( ) ,
96+ String . contains? ( file , "/workflows/" ) ,
97+ workflow = read_yaml ( file ) ,
98+ { job_id , job } <- Map . get ( workflow , "jobs" , % { } ) ,
99+ { step , index } <- Enum . with_index ( Map . get ( job , "steps" , [ ] ) ) ,
100+ run = Map . get ( step , "run" ) ,
101+ is_binary ( run ) ,
102+ pipes_suite? ( run ) ,
103+ not pipefail? ( step , job , workflow , run ) do
104+ "#{ file } : job #{ job_id } , step #{ Map . get ( step , "name" , "##{ index } " ) } "
105+ end
106+
107+ assert offenders == [ ] ,
108+ "these steps pipe `mix test` or `mix gate` into another command, so the step exits " <>
109+ "with that command's status and a red suite passes. Run them unpiped, or give the " <>
110+ "step `shell: bash` (which adds -o pipefail):\n " <> Enum . join ( offenders , "\n " )
111+ end
112+
113+ # A line that runs `mix test` or `mix gate` and has a single `|` after it (not `||`).
114+ defp pipes_suite? ( run ) do
115+ run
116+ |> String . split ( "\n " )
117+ |> Enum . any? ( & Regex . match? ( ~r/ \b mix\s +(test|gate)\b [^|]*\| (?!\| )/ , & 1 ) )
118+ end
119+
120+ defp pipefail? ( step , job , workflow , run ) do
121+ shell =
122+ Map . get ( step , "shell" ) || get_in ( job , [ "defaults" , "run" , "shell" ] ) ||
123+ get_in ( workflow , [ "defaults" , "run" , "shell" ] )
124+
125+ shell == "bash" or String . contains? ( run , "set -o pipefail" ) or
126+ String . contains? ( run , "set -eo pipefail" )
127+ end
128+
86129 # `permissions:` is either a map of scopes, or one of the shorthands `read-all`/`write-all`.
87130 defp top_level_scopes ( "read-all" ) , do: [ ]
88131 defp top_level_scopes ( "write-all" ) , do: [ { "(every scope)" , "write" } ]
0 commit comments