Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions .github/workflows/fips.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
#
# The suite on Erlang/OTP in FIPS mode, against the validated OpenSSL FIPS provider.
#
# What runs: OTP 28.1.1 (the pinned line) built with --enable-fips, over OpenSSL 3.5.8's
# What runs: OTP 28.5.0.8 (a patched release of the pinned line, at or above the CVE-2026-55737
# floor in tools/otp-floors) built with --enable-fips, over OpenSSL 3.5.8's
# libcrypto with the FIPS provider built from OpenSSL 3.1.2, the source CMVP certificate #4985
# (FIPS 140-3) validates, following its security policy (section 11.1: `enable-fips`,
# `make install_fips`, then `openssl fipsinstall` on the machine that runs it). Pairing a newer
Expand All @@ -22,6 +23,11 @@
# Not a validation claim: ubuntu-24.04 is not an operational environment #4985 names, and this
# job proves the package's behaviour under FIPS mode, nothing about the module. See docs/fips.md.
#
# Two architectures: x86_64 and aarch64 (GitHub's arm64 runner, free for public repositories),
# because the fielded edge box is aarch64 and `:crypto` rides on the OTP and OpenSSL built for
# the target. Each builds its own toolchain from the same checked sources; the cache key carries
# the architecture.
#
# When: every push to main and every pull request that touches the code, weekly, and on demand.
# The toolchain is built once and cached (keyed on the versions and this file); a warm run is
# the suite plus a few minutes. Public repository: GitHub-hosted minutes are not billed.
Expand All @@ -46,8 +52,12 @@ permissions:

jobs:
fips:
name: FIPS - OTP 28.1.1, OpenSSL FIPS provider 3.1.2 (CMVP 4985)
runs-on: ubuntu-24.04
name: FIPS - OTP 28.5.0.8, OpenSSL FIPS provider 3.1.2 (CMVP 4985), ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, ubuntu-24.04-arm]
timeout-minutes: 45
env:
# Fixed absolute paths: the rpath, OpenSSL's MODULESDIR and OTP's ROOTDIR are baked in.
Expand All @@ -61,9 +71,9 @@ jobs:
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: /home/runner/fips
key: fips-${{ runner.os }}-${{ runner.arch }}-ubuntu24-otp28.1.1-ossl3.5.8-fipsprov3.1.2-ex1.18.4-${{ hashFiles('.github/workflows/fips.yml') }}
key: fips-${{ runner.os }}-${{ runner.arch }}-ubuntu24-otp28.5.0.8-ossl3.5.8-fipsprov3.1.2-ex1.18.4-${{ hashFiles('.github/workflows/fips.yml') }}

- name: build OpenSSL 3.5.8 (libcrypto), the 3.1.2 FIPS provider, and OTP 28.1.1 --enable-fips
- name: build OpenSSL 3.5.8 (libcrypto), the 3.1.2 FIPS provider, and OTP 28.5.0.8 --enable-fips
if: steps.cache.outputs.cache-hit != 'true'
run: |
set -euo pipefail
Expand All @@ -72,12 +82,12 @@ jobs:
dl() { curl -fsSLO "$1"; echo "$2 $(basename "$1")" | sha256sum -c -; }
dl https://github.com/openssl/openssl/releases/download/openssl-3.5.8/openssl-3.5.8.tar.gz a8f84a39918ec6415ce765d9b429d313ba97b8143169c172e734b9514464f5b2
dl https://github.com/openssl/openssl/releases/download/openssl-3.1.2/openssl-3.1.2.tar.gz a0ce69b8b97ea6a35b96875235aa453b966ba3cba8af2de23657d8b6767d6539
dl https://github.com/erlang/otp/releases/download/OTP-28.1.1/otp_src_28.1.1.tar.gz 03e1b26e846b2cc1b49271656da6d5ecf4d5f4c2e34224d869a23c61a3d5660a
dl https://github.com/erlang/otp/releases/download/OTP-28.5.0.8/otp_src_28.5.0.8.tar.gz 8a696e15bcbe5d57197b1f4c03c92d415f9fd9af70f9222a05911c8a5463cba6
dl https://builds.hex.pm/builds/elixir/v1.18.4-otp-28.zip cbd98899abdd4b1243d8ea6dae7258f9bc71cc2dcb55b8f9c241b07f04b3de67
for t in openssl-3.5.8 openssl-3.1.2 otp_src_28.1.1; do tar xzf "$t.tar.gz"; done
for t in openssl-3.5.8 openssl-3.1.2 otp_src_28.5.0.8; do tar xzf "$t.tar.gz"; done
(cd openssl-3.5.8 && ./Configure --prefix="$P" --openssldir="$P/ssl" --libdir=lib shared no-docs && make -j4 && make install_sw)
(cd openssl-3.1.2 && ./Configure enable-fips --prefix="$P" --openssldir="$P/ssl" --libdir=lib shared && make -j4 && make install_fips)
(cd otp_src_28.1.1 && ./configure --prefix="$OTP" --enable-fips --with-ssl="$P" --with-ssl-rpath="$P/lib" --without-javac --without-odbc --without-wx && make -j4 && make install)
(cd otp_src_28.5.0.8 && ./configure --prefix="$OTP" --enable-fips --with-ssl="$P" --with-ssl-rpath="$P/lib" --without-javac --without-odbc --without-wx && make -j4 && make install)
mkdir -p "$EX" && unzip -q v1.18.4-otp-28.zip -d "$EX"

- name: fipsinstall on this machine, and the configuration that activates the provider
Expand All @@ -92,6 +102,10 @@ jobs:
echo "$OTP/bin" >> "$GITHUB_PATH"
echo "$EX/bin" >> "$GITHUB_PATH"

# This job runs no gate, so the floor the gate holds every other leg to is asserted here:
# the OTP it just built is at or above its line's patched release (tools/otp-floors).
- name: the OTP built here is at or above its patched floor
run: bash tools/otp_floor.sh
- name: FIPS mode is on, served by the 3.1.2 provider, and fails closed without it
run: |
set -euo pipefail
Expand Down
21 changes: 15 additions & 6 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,18 @@ on:
# image is observed, not assumed: the first run's log is the evidence, and the slice record
# quotes it. `fail-fast: false` so one leg's red does not hide another's measurement.
#
# THE BOX LEG: the pair a fielded edge box runs (OTP 28 at a patched release, Elixir 1.20), on
# the architecture it runs on (aarch64, GitHub's arm64 runner, free for public repositories).
# Its versions are exact (`version-type: strict`), not "the newest 28": the box pins its
# runtime, so the leg does too. Every leg's gate runs the OTP floor step (tools/otp_floor.sh),
# so a resolution below a line's patched floor is a red leg, not a green one on a vulnerable
# runtime.
#
# THE REQUIRED CHECK IS THE SUMMARY JOB. The repository's ruleset on main requires the status
# context "Gate - quality gate" by name; a matrix leg's context carries its values, so no leg
# could satisfy it and a pull request would wait on a check that never reports. `summary`
# carries that exact name, runs after every leg (`if: always()`), and fails unless the legs
# all succeeded -- so the ruleset stays as it is and the required check means "all three".
# all succeeded -- so the ruleset stays as it is and the required check means "every leg".
# The token does nothing here but read: the legs read the tree, the summary reads check runs.
# (What the Scorecard's Token-Permissions check asks for, and what the jobs need anyway.)
permissions:
Expand All @@ -36,15 +43,16 @@ permissions:
jobs:
gate:
name: Gate - leg (OTP ${{ matrix.otp }}, Elixir ${{ matrix.elixir }}, ${{ matrix.leg }})
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
if: github.event_name != 'pull_request' || github.event.action != 'edited'
strategy:
fail-fast: false
matrix:
include:
- { leg: floor, otp: "27", elixir: "1.17" }
- { leg: pinned, otp: "28", elixir: "1.18" }
- { leg: head, otp: "29", elixir: "1.20" }
- { leg: floor, otp: "27", elixir: "1.17", os: ubuntu-latest, version-type: loose }
- { leg: pinned, otp: "28", elixir: "1.18", os: ubuntu-latest, version-type: loose }
- { leg: head, otp: "29", elixir: "1.20", os: ubuntu-latest, version-type: loose }
- { leg: box, otp: "28.5.0.8", elixir: "1.20.4", os: ubuntu-24.04-arm, version-type: strict }
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -54,6 +62,7 @@ jobs:
with:
otp-version: ${{ matrix.otp }}
elixir-version: ${{ matrix.elixir }}
version-type: ${{ matrix.version-type }}
- name: the pair setup-beam installed (observed, quoted in the slice record)
run: |
erl -noshell -eval 'io:format("OTP ~s~n", [erlang:system_info(otp_release)]), halt().'
Expand All @@ -67,7 +76,7 @@ jobs:
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: _build/dialyzer
key: dialyzer-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('mix.lock') }}
key: dialyzer-${{ runner.arch }}-${{ steps.beam.outputs.otp-version }}-${{ steps.beam.outputs.elixir-version }}-${{ hashFiles('mix.lock') }}
# The leg says what it claims to be, so the gate can hold it to it: on the pinned leg a
# format step that cannot measure (the running Elixir is not `.tool-versions`' line) is a
# FAIL, not NOT MEASURED -- otherwise a pin moved in `.tool-versions` and not here would
Expand Down
2 changes: 1 addition & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
erlang 28.1.1
erlang 28.5.0.8
elixir 1.18.4-otp-28
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ back, whoever wrote it.
## Code review

**How it is done.** Every change reaches `main` through a pull request, the maintainer's
included, and is merged by rebase only after CI is green on all three OTP/Elixir pairs. The
included, and is merged by rebase only after CI is green on all four OTP/Elixir legs. The
reviewer reads the diff, the commit messages and the CI results, and runs the change locally
when it touches the wire or the host contract. `CONVENTIONS.md`'s tier rule sets the depth: a
contract change (behaviour a consumer can hit) gets two independent review passes and mutation
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -556,7 +556,7 @@ has no stdio server mode). `conformance/README.md` has the rest.
**Reproduce it:** `tools/conformance.sh`, one command, for anyone with Node ≥ 22 and
`python3`. **The trade, stated:** this package has two dependencies; producing this number
costs a second toolchain, so the step runs in a CI job of its own (`conformance.yml`, Node 22
pinned) and never in the local gate, which stays the sixteen steps a contributor with Elixir
pinned) and never in the local gate, which stays the seventeen steps a contributor with Elixir
and Erlang runs green with nothing else installed (Dialyzer among them, on the OTP binary; its
PLT is built once per machine, about a minute, and kept under `_build`). The gate needs hex.pm (two steps resolve
dependencies); of those, the dependency audit is the one that refuses an answer hex gives
Expand Down
2 changes: 1 addition & 1 deletion docs/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ partial scheme.

## How the case is kept true

- The gate runs on every push on three OTP/Elixir pairs; the censuses above fail when the code
- The gate runs on every push on four OTP/Elixir legs (three pairs on x86_64, and the box pair, OTP 28.5.0.8 with Elixir 1.20.4, on aarch64), each held to its line's patched OTP floor; the censuses above fail when the code
they describe changes.
- The threat model's citations and the will-not-implement page's entries are each held to the
test suite by a census of their own.
Expand Down
7 changes: 4 additions & 3 deletions docs/fips.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,15 +97,16 @@ provider) and of the process that certified it, and this package holds no such m
## Measured: this package in FIPS mode

`.github/workflows/fips.yml` runs the suite on every push to `main`, on each pull request that
touches the code, and weekly, on this toolchain: Erlang/OTP 28.1.1 (the pinned line) built
with `--enable-fips`, over OpenSSL 3.5.8's libcrypto with the FIPS provider built from
touches the code, and weekly, on this toolchain, on x86_64 and on aarch64: Erlang/OTP
28.5.0.8 (a patched release of the pinned line; until 2026-10-19 it was 28.1.1, below the
CVE-2026-55737 fix) built with `--enable-fips`, over OpenSSL 3.5.8's libcrypto with the FIPS provider built from
OpenSSL 3.1.2, the source that CMVP certificate #4985 (FIPS 140-3) validates, following its
security policy (`enable-fips`, `make install_fips`, `openssl fipsinstall` on the machine
that runs it). Before any test runs, the same VM asserts `:crypto.info_fips()` is `:enabled`
and the provider reports build `3.1.2`; a negative control shows that without the provider
configuration `fips_mode true` gives no working `crypto`.

Measured 2026-09-23 on that toolchain: `:crypto.info_fips()` answers `:enabled`,
Measured 2026-09-23 on the 28.1.1 toolchain, x86_64 (the 28.5.0.8 runs on both architectures are quoted in the next release's record, from their CI logs): `:crypto.info_fips()` answers `:enabled`,
`fips_provider_available: true`, `fips_provider_buildinfo: "3.1.2"`, the linked library
OpenSSL 3.5.8; `:crypto.hash(:md5, _)` raises `notsup` ("Bad digest type in FIPS");
SHA-256, SHA-384 and SHA-512 answer, SHA-256 of `abc` being the FIPS 180-4 test vector; and
Expand Down
10 changes: 5 additions & 5 deletions docs/governance.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@ Every change, the maintainer's included, goes the same way:
force-push, no deletion, linear history, and two required status checks: the DCO sign-off
and the quality gate. A pull request is the only way onto `main`, and it is rebased, never
merged, so the history is a line.
2. **The gate.** `tools/gate.sh` runs the same sixteen steps locally and in CI (format,
compile with warnings as errors, Dialyzer, the instruments' parse, the suite, Credo, the
2. **The gate.** `tools/gate.sh` runs the same seventeen steps locally and in CI (format,
the runtime's patched OTP floor, compile with warnings as errors, Dialyzer, the instruments' parse, the suite, Credo, the
properties, the optional-dependency probe, the dependency audit, the benchmarks, the docs,
REUSE, the licence files, the publication census, the public-API baseline against
`origin/main` and the commit-message terms) on three
OTP/Elixir pairs (the floor, the pinned line, the newest). There is no baseline to hold: a
`origin/main` and the commit-message terms) on four
OTP/Elixir legs (the floor, the pinned line, the newest, and the box pair on aarch64). There is no baseline to hold: a
non-zero count is a failure.
3. **Review by tier, then the merge word.** `CONVENTIONS.md` states the tier rule: a contract
change (behaviour a consumer can hit) gets two independent review lanes and mutation
Expand Down Expand Up @@ -73,7 +73,7 @@ promised, and moves when the Scorecard next runs.
| License | `LICENSE`, `NOTICE`, `LICENSES/`, REUSE headers on every file, held by the gate | | 10 |
| Dependency-Update-Tool | Dependabot, weekly, Mix and GitHub Actions | | 10 |
| Vulnerabilities | `mix hex.audit` in the gate, OSV-fed, on every push | | 10 |
| CI-Tests | the gate on three OTP/Elixir pairs | | 10, "7 out of 7 merged PRs checked" |
| CI-Tests | the gate on four OTP/Elixir legs | | 10, "7 out of 7 merged PRs checked" |
| Maintained | commits and releases as the CHANGELOG shows | the check scores **0 for any repository younger than 90 days**, whatever its activity; this one was created 2026-09-06, so the figure is the rule's until 2026-12-05 and says nothing about the tree | 0, "created within the last 90 days" |
| Signed-Releases | releases are Hex releases: from `0.6.0` on, the tarball is built by CI on the tag and attested (`docs/provenance.md`; `0.5.0` and earlier carry none); there are no GitHub Releases with assets for this check to read | the attestation binds to the checksum hex.pm shows, which is where consumers fetch from; a GitHub Release would be a copy | −1, "no releases found": the check reads GitHub Releases only |
| Packaging | the package is published to hex.pm by the maintainer from the canonical tarball, on a signed tag; no GitHub Actions publishing workflow | the publish step holds a Hex API key, which stays on the maintainer's seat rather than in a workflow secret: a decision, recorded here; the provenance workflow attests the bytes but does not publish them | −1, "packaging workflow not detected": the check reads a publishing workflow only |
Expand Down
21 changes: 18 additions & 3 deletions test/beam_mcp/transport/http_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -1645,12 +1645,27 @@ defmodule BeamMCP.Transport.HTTPTest do
PlantedAdapter.wrap(conn, raise_in, exception)
end

# Returns the exception and stacktrace `HTTP.call/2` raised, and what was logged meanwhile.
# What the calling process logged while `fun` ran. `capture_log/2` hands every capture the
# events of every process, so in an async run another module's errors land in it too (the
# box leg of gate run 39324309977: readme_claims_test.exs's refused-schema line failed a
# `refute log =~ "[error]"` here). This transport logs from the process that called it, which
# in these tests is the test's own, so keeping that process's entries drops nothing it logs.
defp own_log(fun) do
me = "pid=#{:erlang.pid_to_list(self())} "

[metadata: [:pid]]
|> ExUnit.CaptureLog.capture_log(fun)
|> String.split("\n")
|> Enum.filter(&String.contains?(&1, me))
|> Enum.join("\n")
end

# Returns the exception and stacktrace `HTTP.call/2` raised, and what it logged meanwhile.
defp raised_by_call(conn) do
parent = self()

log =
ExUnit.CaptureLog.capture_log(fn ->
own_log(fn ->
try do
HTTP.call(conn, opts())
send(parent, {:returned, :no_raise})
Expand Down Expand Up @@ -1745,7 +1760,7 @@ defmodule BeamMCP.Transport.HTTPTest do
conn = planted_post(:read_req_body, timeout)

log =
ExUnit.CaptureLog.capture_log(fn ->
own_log(fn ->
conn = HTTP.call(conn, opts())
assert conn.status == 408
assert body!(conn)["error"]["code"] == -32_600
Expand Down
8 changes: 4 additions & 4 deletions tools/ci_legs_verdict.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,25 @@
# The edited-summary read, out of the workflow and into a script a probe can run with a fake
# `gh`. On a pull-request body edit the legs do not run (the tree did not move) but the
# summary job carries the REQUIRED context, so it reads the legs' latest check runs for the
# head SHA and requires all three to be success. G-079 measured the hole: a body edit while the
# head SHA and requires every one to be success. G-079 measured the hole: a body edit while the
# same SHA's legs were still running read their conclusions as null and failed "by design",
# and the ruleset then blocked the pull request on that red although the legs finished green a
# minute later -- a failed run of the required name blocks even beside a later green one. So
# this waits: it polls until every leg check-run for the SHA is `completed`, bounded, and only
# then judges. A tree whose legs never finish is a FAIL at the bound, named as such.
#
# GH_TOKEN=... tools/ci_legs_verdict.sh <owner/repo> <head sha>
# env: LEGS_MIN (default 3), POLL_SECONDS (default 30), POLL_LIMIT (default 40 -- 20 minutes).
# env: LEGS_MIN (default 4: floor, pinned, head, box), POLL_SECONDS (default 30), POLL_LIMIT (default 40 -- 20 minutes).
# exit 0: every leg completed and success; 1: a leg not success, too few legs, or the bound hit.
set -uo pipefail
[ $# -eq 2 ] || { echo "usage: tools/ci_legs_verdict.sh <owner/repo> <head-sha>" >&2; exit 2; }
repo=$1 sha=$2
min=${LEGS_MIN:-3} every=${POLL_SECONDS:-30} limit=${POLL_LIMIT:-40}
min=${LEGS_MIN:-4} every=${POLL_SECONDS:-30} limit=${POLL_LIMIT:-40}
# The expanded leg names only: the skipped matrix job of the edited run registers one
# unexpanded name ("Gate - leg (OTP ${{ matrix.otp }} ...", skipped), which is not a verdict.
read_legs() {
gh api "repos/${repo}/commits/${sha}/check-runs?filter=latest&per_page=100" \
--jq '.check_runs[] | select(.name | test("^Gate - leg \\(OTP [0-9]+, Elixir [0-9.]+, [a-z]+\\)$")) | "\(.name): \(.status)/\(.conclusion)"'
--jq '.check_runs[] | select(.name | test("^Gate - leg \\(OTP [0-9][0-9.]*, Elixir [0-9.]+, [a-z]+\\)$")) | "\(.name): \(.status)/\(.conclusion)"'
}
i=0
while :; do
Expand Down
6 changes: 6 additions & 0 deletions tools/gate.sh
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ else
printf '%s\n' "$fmt_out" | sed 's/^/ /'; fail=1
fi
fi
# The runtime is at or above its line's patched floor (tools/otp-floors: CVE-2026-55737). The
# leg's own OTP, whatever setup-beam resolved, so a resolution below the floor is a FAIL here
# rather than a green leg on a vulnerable runtime.
of_out=$(bash tools/otp_floor.sh 2>&1); of_rc=$?
if [ "$of_rc" -eq 0 ]; then note "otp floor" "pass ($of_out)"
else note "otp floor" "FAIL ($of_out)"; fail=1; fi
step "compile" mix compile --warnings-as-errors --force

# Dialyzer over the compiled beams: the OTP binary (dialyxir declined, CX-055), a PLT under
Expand Down
Loading
Loading