Skip to content

Commit efe2d8a

Browse files
authored
Merge pull request #30 from ausimian/issue/8-coverage
test: cover the shipped surface, and make the coverage figure mean something
2 parents 2eea154 + ad53918 commit efe2d8a

9 files changed

Lines changed: 944 additions & 4 deletions

File tree

.github/workflows/ci.yml

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
run: mix precommit
4040

4141
test:
42-
name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }})
42+
name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }})${{ matrix.cover && ' + coverage floor' || '' }}
4343
# Only Linux for now. Castle is pure Elixir talking to :release_handler,
4444
# with no shell scripts of its own and no suite that boots a release, so
4545
# there is nothing platform-sensitive to run. Add macos-15 alongside the
@@ -60,6 +60,27 @@ jobs:
6060
# Elixir 1.19 supports Erlang/OTP 26-28
6161
- elixir: '1.19'
6262
otp: '29'
63+
include:
64+
# **The one cell that checks the coverage floor, and the only place in
65+
# CI that checks it at all.** `mix precommit` enforces the threshold in
66+
# `mix.exs`, but the `precommit` job below is pinned to a single
67+
# Elixir, so on its own it says nothing about the rest of the
68+
# `~> 1.18` range that `mix.exs` declares - and cover's line
69+
# attribution is not constant across that range. Elixir 1.20 counts
70+
# one more relevant line in `Castle.Peer` than 1.19 does, which is how
71+
# a floor set from the pinned toolchain came to fail a clean tree for
72+
# contributors on a current Elixir.
73+
#
74+
# This is the newest Elixir in the matrix, so it reports the lowest
75+
# figure and trips first: a canary for exactly that drift. Every other
76+
# cell stays on a plain `mix test` deliberately - seven readings that
77+
# disagree by version would be noise, not signal.
78+
#
79+
# Keep this on whichever cell is newest when the matrix moves on. The
80+
# threshold note in `mix.exs` explains what to do if it goes red.
81+
- elixir: '1.20'
82+
otp: '29'
83+
cover: true
6384

6485
steps:
6586
- name: Checkout code
@@ -91,4 +112,23 @@ jobs:
91112
run: mix compile --warnings-as-errors
92113

93114
- name: Run tests
115+
if: ${{ !matrix.cover }}
94116
run: mix test
117+
118+
# Split in two rather than one `mix test --cover`, so that the step which
119+
# goes red names the thing that broke without anyone opening the log:
120+
# exporting cannot fail on coverage, and checking cannot fail on a test.
121+
- name: Run tests (exporting coverage)
122+
if: ${{ matrix.cover }}
123+
run: mix test --cover --export-coverage ci
124+
125+
- name: Check the coverage floor
126+
# Red here means the total fell under the threshold in `mix.exs` while the
127+
# tests passed. Either coverage genuinely dropped, or this Elixir
128+
# attributes relevant lines differently from the pinned toolchain - read
129+
# the threshold note in `mix.exs` before changing the number, because it
130+
# is a floor across the whole supported range and not a reading from one.
131+
if: ${{ matrix.cover }}
132+
env:
133+
MIX_ENV: test
134+
run: mix test.coverage

AGENTS.md

Lines changed: 217 additions & 1 deletion
Large diffs are not rendered by default.

RELEASE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ Forecastle 1.x and Elixir 1.18 or later.
4141
the shared Erlang installation.
4242
- Give actionable recovery instructions when `:release_handler` booted without
4343
an accepted `RELEASES` file.
44+
- Fix the wording when a restart install is refused because something unusual,
45+
such as a named pipe, is already at the path Castle uses for its restart
46+
marker. The message used to read "a other".
4447
- Report restart installs without raising `CaseClauseError`.
4548
- Return an empty release list without raising `Enum.EmptyError`.
4649
- Report `RELEASES` read and write errors instead of raising `MatchError`.

lib/castle/commands.ex

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1260,8 +1260,16 @@ defmodule Castle.Commands do
12601260
"Move whatever is there out of the way."
12611261
end
12621262

1263+
# Four of `File.lstat/1`'s five types reach here - `:regular` is the pending
1264+
# marker and is answered above - and each has to read as a noun phrase in that
1265+
# sentence. `:other` is the awkward one: a named pipe, a socket, anything the
1266+
# emulator has no name for, and it went into the shipped message as "a other",
1267+
# which is what a `"a #{type}"` catch-all does with the one type whose atom is
1268+
# not a noun. The catch-all stays for `:device` and for whatever OTP adds,
1269+
# since "a device" reads correctly.
12631270
defp describe_type(:directory), do: "a directory"
12641271
defp describe_type(:symlink), do: "a symbolic link"
1272+
defp describe_type(:other), do: "something of another kind"
12651273
defp describe_type(other), do: "a #{other}"
12661274

12671275
defp stale(provisional, vsn, reason, refusal) do

mix.exs

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ defmodule Castle.MixProject do
1616
aliases: aliases(),
1717
package: package(),
1818
docs: docs(),
19+
test_coverage: test_coverage(),
1920
source_url: @source_url
2021
]
2122
end
@@ -38,6 +39,85 @@ defmodule Castle.MixProject do
3839
defp elixirc_paths(:test), do: ["lib", "test/support"]
3940
defp elixirc_paths(_env), do: ["lib"]
4041

42+
# `mix test --cover` measures the shipped code, which is `lib` - the modules
43+
# under `test/support` are fixtures, and a fixture is covered by being run at
44+
# all. Left in, they moved the total without ever being the thing measured, and
45+
# two of them moved it *down* for a reason that is not about tests:
46+
# `Castle.PeerProviderStub` and most of `Castle.IoSink` execute inside the peer
47+
# VM, which nothing instruments - see the threshold below. Their code genuinely
48+
# runs and genuinely cannot be observed from here, so the figure they
49+
# contributed was an artefact of where they run rather than a gap in the suite,
50+
# and raising it would have meant calling them directly on the test node, which
51+
# tests nothing.
52+
#
53+
# Named module by module rather than matched by a pattern. A regex over `Stub`
54+
# or over `Castle.*Release` would quietly swallow a production module that
55+
# happened to be spelled that way, which is the one thing an exclusion list
56+
# must not do. Renaming a fixture makes the total drop, which is visible.
57+
#
58+
# **The threshold is a floor across the supported range, not this machine's
59+
# reading.** `elixir: "~> 1.18"` invites in every version from 1.18 upwards,
60+
# and cover's line attribution is not the same across them: 1.18.3 through
61+
# 1.19.5 count 473 relevant lines and report 88.58%, while 1.20.3 counts one
62+
# more in `Castle.Peer` and reports 88.40%. Same tests, same covered lines, a
63+
# different denominator. So this is the *lowest* of those readings, rounded
64+
# down, so that it absorbs a line of drift instead of sitting on one version's
65+
# figure. The per-toolchain measurements are in AGENTS.md.
66+
#
67+
# Two earlier values were wrong in opposite directions, and both mistakes are
68+
# worth keeping written down. 85 sat *below* the figure it was meant to floor,
69+
# so it ratcheted nothing and licensed a thirteen-line regression. 88.58 was
70+
# the pinned toolchain's exact reading with no slack, which made the *mandatory*
71+
# `mix precommit` fail on a clean tree under Elixir 1.20 - a false failure for
72+
# any contributor on a current release, and the comment beside it had already
73+
# said attribution varies by version while the number ignored it. So: do not
74+
# set this from one machine, and do not raise it to 88.40 or above, which
75+
# re-creates the trap the moment another version attributes differently.
76+
#
77+
# 88 absorbs two uncovered lines added to `lib` and fails on the third, which
78+
# was measured on 1.20 (the least slack of the range) rather than estimated -
79+
# 88.40, 88.21, 88.03, then 87.84 and exit 3. That is looser than a floor
80+
# ideally is, and it is the deliberate price of enough headroom that one more
81+
# line of attribution drift does not fail a clean tree. The direction of the
82+
# trade is the point: a floor that fires on a clean tree teaches people to
83+
# bypass the gate.
84+
#
85+
# 90% would need 426 covered on 1.19's denominator, seven more than there are.
86+
# What is left there is 33 lines in the peer's VM (below) plus 21 observable in
87+
# principle: five are the compiler's own default-argument clauses for arities
88+
# nothing calls, and the other sixteen need a file mode, a device node, or a
89+
# config provider sabotaging Castle's working directory. So the seven would
90+
# have to include all five of the default-argument clauses, whose only effect
91+
# is on this number. That is the move this project does not make. See AGENTS.md
92+
# for the line-by-line account.
93+
#
94+
# **What cannot be measured is the peer's VM, and the reason is where
95+
# instrumentation is applied rather than anything cover cannot do.**
96+
# `:cover.start/0` works perfectly well in a VM with no node name -
97+
# `is_alive() == false` is no obstacle to it. What happens here is that Mix
98+
# starts cover on *this* node and instruments the modules loaded here; the peer
99+
# is a separate VM that loads `Castle.Peer` from the target release's own beam
100+
# files on disk, which nothing has instrumented. Cover's only mechanism for
101+
# another VM is `:cover.start/1` over a *distributed* node, and this peer
102+
# deliberately has no distribution at all. So `resolve/1` and everything below
103+
# the `## In the peer` comment - 33 lines, about 7% of the shipped total - run
104+
# on every `Castle.PeerTest` and are counted as missed, which puts the
105+
# observable ceiling near 93%.
106+
defp test_coverage do
107+
[
108+
summary: [threshold: 88],
109+
ignore_modules: [
110+
Castle.DeploymentStub,
111+
Castle.InitStub,
112+
Castle.IoSink,
113+
Castle.PeerProviderStub,
114+
Castle.PeerStub,
115+
Castle.ReleaseHandlerStub,
116+
Castle.SyntheticRelease
117+
]
118+
]
119+
end
120+
41121
# Run "mix help deps" to learn about dependencies.
42122
defp deps do
43123
[
@@ -59,7 +139,19 @@ defmodule Castle.MixProject do
59139
"deps.unlock --unused",
60140
"format",
61141
"credo --strict",
62-
"test"
142+
# With `--cover`, so the threshold in `test_coverage/0` is a gate rather
143+
# than decoration: nothing else runs it, and a floor nothing enforces is
144+
# a number in a comment.
145+
#
146+
# **This runs on whatever Elixir the contributor has**, which is the
147+
# thing to keep in mind before touching the threshold. CI's `test` matrix
148+
# stays on a plain `mix test` and CI's `precommit` job is pinned to one
149+
# version, so a floor set from that one version is not checked anywhere
150+
# against the rest of the `~> 1.18` range - it is checked here, on a
151+
# machine CI never sees, in the gate this project makes mandatory. That
152+
# is why the number is a floor across the range and not a reading. See
153+
# `test_coverage/0`.
154+
"test --cover"
63155
]
64156
]
65157
end

0 commit comments

Comments
 (0)