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
38 changes: 30 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# The same three OTP/Elixir pairs as beam_mcp (floor, pinned, head): format, compile with
# warnings as errors, Credo --strict (.credo.exs), the Hex audit (retired packages and OSV
# advisories against mix.lock), the suite; the REUSE lint over the tree; and the DCO sign-off
# on every commit, as beam_mcp's gate workflow checks it. Actions pinned by commit SHA with the version beside it.
# The same four OTP/Elixir legs as beam_mcp (floor, pinned, head, box): the OTP floor
# (tools/otp_floor.sh), format, compile with warnings as errors, Credo --strict (.credo.exs),
# the Hex audit (retired packages and OSV advisories against mix.lock), the suite; the REUSE
# lint over the tree; and the DCO sign-off on every commit, as beam_mcp's gate workflow checks
# it. Actions pinned by commit SHA with the version beside it.
#
# 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),
# over the OpenSSL line the box's image pins (3.5 or later, which ML-DSA needs): Ubuntu 26.04's
# runner ships 3.5.5, and builds.hex.pm's OTP for it links that, where 24.04's links 3.0.13.
# The leg sets MLDSA_REQUIRED, so an OTP without ML-DSA-87 is a red leg (tools/mldsa_offered.sh).
# Its versions are exact (`version-type: strict`), not "the newest 28": the box pins its
# runtime, so the leg does too. Every leg runs the OTP floor step, so a resolution below a
# line's patched floor (tools/otp-floors, CVE-2026-55737) is a red leg, not a green one on a
# vulnerable runtime.
name: ci

on:
Expand All @@ -18,23 +29,34 @@ permissions:
jobs:
test:
name: test (OTP ${{ matrix.otp }}, Elixir ${{ matrix.elixir }}, ${{ matrix.leg }})
runs-on: ubuntu-24.04
runs-on: ${{ matrix.os }}
timeout-minutes: 15
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-24.04, version-type: loose, mldsa: "0" }
- { leg: pinned, otp: "28", elixir: "1.18", os: ubuntu-24.04, version-type: loose, mldsa: "0" }
- { leg: head, otp: "29", elixir: "1.20", os: ubuntu-24.04, version-type: loose, mldsa: "0" }
- { leg: box, otp: "28.5.0.8", elixir: "1.20.4", os: ubuntu-26.04-arm, version-type: strict, mldsa: "1" }
env:
MIX_ENV: test
MLDSA_REQUIRED: ${{ matrix.mldsa }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: erlef/setup-beam@54075bcc5e249e4758d363f27d099f55d843f124 # v1.24.1
with:
otp-version: ${{ matrix.otp }}
elixir-version: ${{ matrix.elixir }}
version-type: ${{ matrix.version-type }}
# Also printed on every leg: the OpenSSL `:crypto` is linked to and whether it offers
# ML-DSA-87; asserted where the leg sets MLDSA_REQUIRED (the box leg).
- name: the pair setup-beam installed, at or above its line's patched OTP floor
run: |
erl -noshell -eval 'io:format("~s~n", [erlang:system_info(system_version)]), halt().'
elixir --version | tail -1
bash tools/mldsa_offered.sh
bash tools/otp_floor.sh
- run: mix deps.get
- run: mix format --check-formatted
- run: mix compile --warnings-as-errors
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
5 changes: 3 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ mix test

Erlang/OTP 27 or newer and Elixir 1.17 or newer, the same floor as `beam_mcp`. CI runs
`mix format --check-formatted`, `mix compile --warnings-as-errors`, `mix credo --strict`,
`mix hex.audit` and `mix test` on three OTP/Elixir pairs; run the same before you push.
`mix hex.audit` and `mix test` on four OTP/Elixir legs, after checking the runtime is at or above
its line's patched OTP floor (`tools/otp_floor.sh`); run the same before you push.

## The rules, all enforced

Expand All @@ -42,7 +43,7 @@ Erlang/OTP 27 or newer and Elixir 1.17 or newer, the same floor as `beam_mcp`. C
## Code review

**How it is done.** Every change reaches `main` through a pull request, the maintainer's
included, rebased after CI is green on all three OTP/Elixir pairs. The reviewer reads the diff,
included, rebased 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 suite locally for any change under `lib/`.

**What must be checked.**
Expand Down
7 changes: 4 additions & 3 deletions GOVERNANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,14 @@ date, so the bus factor is two, and his reviews are the public record of it.
## How a change lands

1. A branch and a pull request; nothing is pushed to `main` directly.
2. CI on three OTP/Elixir pairs (the floor, the pinned line, the newest), the REUSE lint and
the DCO check.
2. CI on four OTP/Elixir legs (the floor, the pinned line, the newest, and the box: OTP
28.5.0.8 with Elixir 1.20.4 on aarch64, over an OpenSSL that offers ML-DSA-87), the REUSE
lint and the DCO check.
3. The maintainer's review, then a rebase merge.

From 2026-09-26 a repository ruleset on `main` holds steps 1 to 3 rather than leaving them to
habit, as `beam_mcp`'s does: no deletion or force-push, changes by pull request only, a rebase
merge and linear history, and the five checks above required and up to date, with no bypass.
merge and linear history, and the six checks above required and up to date, with no bypass.
4. A release is a signed tag and a hex.pm publish by the maintainer, recorded in the
CHANGELOG; `docs/verifying-releases.md` says how to check one.

Expand Down
2 changes: 1 addition & 1 deletion docs/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,5 @@ core answers such a call instead of raising.

## How the case is kept true

CI runs the suite, the census and Credo on every push on three OTP/Elixir pairs; a change to how
CI runs the suite, the census and Credo on every push on four OTP/Elixir legs; a change to how
the key is read or returned changes this page in the same pull request (`GOVERNANCE.md`).
29 changes: 29 additions & 0 deletions tools/mldsa_offered.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# Prints the OpenSSL that `:crypto` is linked to and whether it offers ML-DSA-87. With
# MLDSA_REQUIRED=1 a runtime that does not offer it is refused by name, so a leg that must run
# ML-DSA cannot pass without it. ML-DSA arrived in OpenSSL 3.5; an OTP linked to an older
# libcrypto (Ubuntu 24.04's 3.0.13, which builds.hex.pm's OTPs link there) has none. Run by
# every leg of CI's test job (.github/workflows/ci.yml); the box leg sets MLDSA_REQUIRED=1.
#
# Not a FIPS-mode check: in FIPS mode OTP leaves `mldsa87` out of `crypto:supports/1` while
# the algorithm still works, so a check meant for that mode signs and verifies a known answer
# instead. No leg here runs in FIPS mode.
set -euo pipefail
# The application is started first (a bare module load logs a fips_mode report on some
# runtimes), and only the tagged line is read, so nothing else the runtime prints is parsed.
line=$(erl -noshell -eval '
{ok, _} = application:ensure_all_started(crypto),
[{_, _, Lib}] = crypto:info_lib(),
Offered = lists:member(mldsa87, proplists:get_value(public_keys, crypto:supports())),
io:format("mldsa_offered: ~s ~s~n", [Offered, Lib]),
halt().' | sed -n 's/^mldsa_offered: //p')
offered=${line%% *}
lib=${line#* }
echo "crypto linked to $lib; mldsa87 offered: $offered"
if [ "${MLDSA_REQUIRED:-0}" = "1" ] && [ "$offered" != "true" ]; then
echo "this leg must offer mldsa87 (MLDSA_REQUIRED=1), and its OTP's libcrypto does not; refused"
exit 1
fi
9 changes: 9 additions & 0 deletions tools/otp-floors
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# The lowest patched release of each Erlang/OTP line this project runs on: CVE-2026-55737
# (heap corruption in binary_to_term/1 decoding LARGE_TUPLE_EXT) is fixed in 27.3.4.15,
# 28.5.0.4 and 29.0.4 (https://www.erlang.org/patches/otp-28.5.0.4). tools/otp_floor.sh
# refuses a runtime below its line's floor. A line here moves only to a later patch. A copy of
# beam_mcp's tools/otp-floors (the same floor as beam_mcp): the two files change together.
# major floor
27 27.3.4.15
28 28.5.0.4
29 29.0.4
2 changes: 2 additions & 0 deletions tools/otp-floors.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC
SPDX-License-Identifier: Apache-2.0
39 changes: 39 additions & 0 deletions tools/otp_floor.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC
# SPDX-License-Identifier: Apache-2.0
#
# The running Erlang/OTP is at or above its line's patched floor (tools/otp-floors), or this
# refuses by name. The full version is read from the release's own OTP_VERSION file, not from
# `otp_release` (which is the major alone, so 28.1.1 and 28.5.0.8 both answer "28"). A line
# newer than every recorded one passes and says so; a line older than every recorded one, or a
# recorded line below its floor, is refused. Run by every leg of CI's test job
# (.github/workflows/ci.yml) after the toolchain is installed. A copy of beam_mcp's
# tools/otp_floor.sh: the two packages hold the same floor, and change together.
set -euo pipefail
root=$(git rev-parse --show-toplevel)
floors="$root/tools/otp-floors"
have=$(erl -noshell -eval '
R = erlang:system_info(otp_release),
F = filename:join([code:root_dir(), "releases", R, "OTP_VERSION"]),
{ok, B} = file:read_file(F),
io:format("~s", [string:trim(B)]),
halt().')
have=${have%%[!0-9.]*}
major=${have%%.*}
floor=$(awk -v m="$major" '$1 == m { print $2 }' "$floors")
newest=$(awk '$1 ~ /^[0-9]+$/ { print $1 }' "$floors" | sort -n | tail -1)
if [ -z "$floor" ]; then
if [ "$major" -gt "$newest" ]; then
echo "OTP $have: a line newer than every recorded floor (newest recorded: $newest); passes"
exit 0
fi
echo "OTP $have: line $major has no patched release recorded in tools/otp-floors; refused"
exit 1
fi
lowest=$(printf '%s\n%s\n' "$floor" "$have" | sort -V | head -1)
if [ "$lowest" = "$floor" ]; then
echo "OTP $have: at or above the patched floor $floor for line $major"
else
echo "OTP $have: below the patched floor $floor for line $major (CVE-2026-55737); refused"
exit 1
fi
Loading