diff --git a/.github/workflows/gate.yml b/.github/workflows/gate.yml index 54db751..af2711f 100644 --- a/.github/workflows/gate.yml +++ b/.github/workflows/gate.yml @@ -108,12 +108,16 @@ jobs: runs-on: ubuntu-latest needs: [gate, pr-body] if: always() + # The edited path may wait up to 20 minutes for running legs (tools/ci_legs_verdict.sh's + # bound); the job's own ceiling sits just above it so a hang is a FAIL, not a stall. + timeout-minutes: 25 # The edited path reads the legs' check runs for the head SHA: a read, granted here and # nowhere wider. permissions: contents: read checks: read steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: every leg of the matrix passed, and the pull-request body where there is one env: GH_TOKEN: ${{ github.token }} @@ -126,18 +130,12 @@ jobs: # the REQUIRED context, and a fresh green under that name on a commit whose legs # this run never read would let a title edit stand a green beside a red (a review # lane's finding). So the legs' verdicts for this head SHA are read from the checks - # API -- the latest run of each leg name -- and all three must be success. + # API -- the latest run of each leg name -- and all three must be success. The + # read WAITS for legs still running (G-079: an edit during the synchronize run read + # null conclusions, failed, and the ruleset blocked on that red beside the later + # green); tools/ci_legs_verdict.sh polls to a bound, and its probe runs offline. [ "${{ needs.pr-body.result }}" = "success" ] || exit 1 - # The expanded leg names only: the skipped matrix job of THIS run registers one - # unexpanded name ("Gate - leg (OTP ${{ '{{' }} matrix.otp ...", skipped), which is - # not a verdict (the first edited run measured exactly that). - legs=$(gh api "repos/${GITHUB_REPOSITORY}/commits/${HEAD_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): \(.conclusion)"') - printf '%s\n' "$legs" - n=$(printf '%s\n' "$legs" | grep -c . || true) - bad=$(printf '%s\n' "$legs" | grep -vc ': success$' || true) - [ "$n" -ge 3 ] || { echo "FAIL: only $n leg verdict(s) exist for ${HEAD_SHA}; the tree is unmeasured"; exit 1; } - [ "$bad" -eq 0 ] || { echo "FAIL: a leg's latest verdict for ${HEAD_SHA} is not success"; exit 1; } + bash tools/ci_legs_verdict.sh "${GITHUB_REPOSITORY}" "${HEAD_SHA}" else [ "${{ needs.gate.result }}" = "success" ] case "${{ needs.pr-body.result }}" in success|skipped) ;; *) exit 1 ;; esac diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e1fb7d..170c7de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,42 @@ All notable changes to this project are documented here. The format follows ## [Unreleased] +## [0.8.0] — 2026-09-19 + +The quiet minor: **no public entry added, removed, renamed, hidden or changed in arity** — +`docs/public-api.txt` is line for line `0.7.0`'s, `release_markers!("0.8.0")` wrote nothing, +and the census holds an empty Unreleased section to that. No wire byte and no envelope byte +moves. Instruments and pages only, below. This is the "full minor release unchanged" the +README names as `1.0.0`'s condition for the public API; `1.0.0` follows once it has stood. + +### Changed — instruments (no public entry moves) + +- **The gate diffs `docs/public-api.txt` against `origin/main`** (a sixteenth step, + `baseline`; `tools/baseline_diff.sh`, probed by `tools/probe_baseline_diff.sh`): a public + entry's line deleted by hand, or a `since=`/`deprecated_since=`/`removed_in=` marker arriving + with a release number not above the CHANGELOG's highest heading — one it lists, or a phantom + between two releases — is a FAIL. The census reads the tree + alone and passed both edits green (a review lane measured it); this step reads git. Where + `origin/main` does not resolve the line says so and is not evidence. +- **The pull-request summary waits for running legs** on a body edit (`tools/ci_legs_verdict.sh`, + polling the head SHA's leg check runs until each is completed, bounded at 20 minutes, a + failed API read a poll spent rather than a verdict, probed offline by + `tools/probe_ci_legs_verdict.sh` with a scripted `gh`): an edit during a synchronize run no + longer fails on null conclusions and blocks the merge beside a later green. +- `tools/probe_gate_honesty.sh`'s "other steps not pass" count reads `pass ()` as pass + (it counted every detailed line: 7 of its 13 names on a green gate, 10 of the 16 the gate + prints now; 0 now). + +### Changed — pages (copy) + +- `docs/governance.md`'s Scorecard table carries the check's own figure beside each row + (read 2026-09-19; aggregate 7), with the rule behind each low one — Maintained is 0 for any + repository under 90 days old; Signed-Releases and Packaging read GitHub Releases and a + publishing workflow, neither of which a Hex release has — and two rows the first result + added (Packaging, CII-Best-Practices). +- `docs/provenance.md`'s example commands name `0.7.0`; `docs/connectome-canonical.md`'s + `schema_version` history no longer says `0.6.0` "carries this note". + ## [0.7.0] — 2026-09-19 The signer seam, and nothing else: three public entries added — `BeamMCP.Signer` (the diff --git a/HANDOFF.md b/HANDOFF.md index d950769..a0919b3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -3,10 +3,10 @@ SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC SPDX-License-Identifier: Apache-2.0 --> -# HANDOFF — beam_mcp, release 0.7.0 prepared; publish and tag are the owner's +# HANDOFF — beam_mcp, release 0.8.0 prepared; publish and tag are the owner's Tag and publish are owner steps — never `mix hex.publish`, never push a tag, never bump the -version in `mix.exs`. For 0.7.0 the version bump is this release commit, reviewed like any +version in `mix.exs`. For 0.8.0 the version bump is this release commit, reviewed like any other change; publishing and tagging remain the owner's, in the order the runbook below gives. The slice records — plans, findings, lane reports, signoffs, archived gate runs — live in the @@ -15,7 +15,16 @@ happened. ## State -- **`0.7.0` carries the signer seam and nothing else**: `BeamMCP.Signer` (one callback, +- **`0.8.0` is the quiet minor**: no public entry added, removed, renamed, hidden or changed + in arity — `docs/public-api.txt` is `0.7.0`'s line for line, `release_markers!("0.8.0")` + wrote nothing — and no wire or envelope byte moved. Instruments: the gate's sixteenth step + diffs the baseline against `origin/main` (G-076); the pull-request summary waits for running + legs on a body edit (G-079); the honesty probe's discriminator reads detailed pass lines + (G-078); each with an offline probe. Pages: the governance table carries the Scorecard's + measured figures with the rule behind each low one. `mix.exs` says `0.8.0`; the README + recommends `~> 0.8.0` and the requirement test refuses `0.7.0` and `0.6.0`; the wire + recording's ten version lines are re-taken. `1.0.0` is next, after this minor has stood. +- **`0.7.0` carried the signer seam and nothing else**: `BeamMCP.Signer` (one callback, `sign/2`, two arguments with pinned names), `BeamMCP.Signer.None` (the one no-op under `lib/`) and `BeamMCP.Connectome.Canonical.signature/3` (the one call site, over `encode/2`'s bytes, moving no envelope byte). Three public entries added, none removed, renamed or @@ -24,10 +33,9 @@ happened. callback list, the one `def sign`, the one call site — and sixteen mutants hold it. The signer that holds a key, `BeamMCP.Signer.Ed25519` (Ed25519 through OTP's `:crypto`, the key under `opts[:private_key]`), is the separate package `beam_mcp_signer` - (github.com/ScriptKittyOS/beam_mcp_signer, unpublished until this release is on hex.pm); - this package does not depend on it. `mix.exs` says `0.7.0`; the README recommends - `~> 0.7.0` and the requirement test refuses `0.6.0` and `0.5.0`; the wire recording's ten - version lines are re-taken. + (github.com/ScriptKittyOS/beam_mcp_signer, 0.1.0 on hex.pm, depending on `~> 0.7.0` — so a + host on it cannot take `0.8.0` until a signer release admits it; `UPGRADING.md` says so); + this package does not depend on it. - **`0.6.0` carried everything since `0.5.0`**: the wire hardening after the threat model, the threat-model page, the hash-agile canonical envelope (the release's one break), the install-floor slices (the OTP floor at compile time, the CI matrix on three pairs, the dependency audit, build provenance, the @@ -42,18 +50,20 @@ happened. `docs/public-api.txt` carried no `Unreleased` marker, so `release_markers!("0.6.0")` wrote nothing; at `0.7.0` it wrote three. - **No head hash is written here** — a hash written into the file it describes cannot include - the commit that writes it. `git log v0.6.0..main` is the authority. -- Gate on the release commit: fifteen steps, every line `pass` (the 0.5.0 gate had thirteen; - the audit step made it fourteen in 024 and Dialyzer fifteen in 027a) — format (the tracked set, not a - glob), compile, instruments, test, credo, properties (11 at 1 000 generations), optional - deps, bench (the collector's overhead under the 1.5 µs ceiling; **the diff engine's run and + the commit that writes it. `git log v0.7.0..main` is the authority. +- Gate on the release commit: sixteen steps, every line `pass` (the 0.5.0 gate had thirteen; + the audit step made it fourteen in 024, Dialyzer fifteen in 027a, the baseline diff sixteen + at 0.8.0) — format (the tracked set, not a + glob), compile, dialyzer, instruments, test, credo, properties (11 at 1 000 generations), + optional deps, audit, bench (the collector's overhead under the 1.5 µs ceiling; **the diff engine's run and encode each under its own ceiling now** — 245 ms and 260 ms, medians of five in a fresh process after a warm-up, set at roughly double the stable worst of ten runs on the release head; the reachability queries' cost recorded and judged by no number, but a query refused - on the fixture fails the step by name), docs, reuse, licence files, publication, messages. - **11 properties, 705 tests, 0 failures** on the release tree (692 at 0.6.0, 604 at 0.5.0; - the differences are the slices' own pins — at 0.7.0 the signer seam's census and behaviour - tests; before it the floor, the provenance and security-policy pins, the tracer's session + on the fixture fails the step by name), docs, reuse, licence files, publication, baseline, + messages. + **11 properties, 705 tests, 0 failures** on the release tree (705 at 0.7.0 — 0.8.0 adds no + test, its instruments are probed by shell — 692 at 0.6.0, 604 at 0.5.0; the differences are + the slices' own pins — at 0.7.0 the signer seam's census and behaviour tests; before it the floor, the provenance and security-policy pins, the tracer's session suite, the public-API census on the tree and on fixtures, the governance and export-control censuses). - The README's four-way split is held by census to the modules compiled from `lib/` (the @@ -63,10 +73,10 @@ happened. ## What is next -The road, as `UPGRADING.md` states it and the owner locked it: **`0.7.0`** this release, the -signer seam (`BeamMCP.Signer`, a behaviour added to the public surface and no authority — the -last intentional addition), **`0.8.0`** a quiet minor in which no public entry moves, -**`1.0.0`** after it has stood — the README's condition, that +The road, as `UPGRADING.md` states it and the owner locked it: **`0.7.0`** the signer seam +(`BeamMCP.Signer`, a behaviour added to the public surface and no authority — the last +intentional addition), **`0.8.0`** this release, the quiet minor in which no public entry +moves, **`1.0.0`** after it has stood — the README's condition, that the public API and the stated threat model have each survived a full minor release unchanged. The federation seam stays held on another board's answer and is not on that road. A compiler-tracer census (module-body code run at compile time, which neither the text censuses nor `:xref` see) is @@ -112,11 +122,11 @@ refused. - The `2025-11-25` revision is served on stdio only; over HTTP the conformance row for it is 0 / 30 by design, and the README says so beside the number. -## The release steps — the runbook (followed for 0.6.0; the same for 0.7.0) +## The release steps — the runbook (followed for 0.6.0 and 0.7.0; the same for 0.8.0) 1. The release PR merged to `main` by rebase (the ruleset requires two green checks); `main` is then the release commit. -2. The gate on that commit, fifteen `pass`, output recorded by command and exit code (the +2. The gate on that commit, sixteen `pass`, output recorded by command and exit code (the release PR's own gate run is that record). 3. Tag the release commit **as it sits on `main` after the rebase-merge** (a new SHA; the bytes are a function of the tree, measured) locally, signed (`git tag -s vX.Y.Z`); then diff --git a/README.md b/README.md index 2ffb78d..0296a53 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,11 @@ and refuses one that is not; what a tool *does* is the host's business. ```elixir def deps do - [{:beam_mcp, "~> 0.7.0"}] + [{:beam_mcp, "~> 0.8.0"}] end ``` -**`~> 0.7.0`, not the more usual `~> 0.7`.** While this package is `0.x` it documents breaks +**`~> 0.8.0`, not the more usual `~> 0.8`.** While this package is `0.x` it documents breaks at the **minor** position, and it has used that position five times: `0.2.0` removed two fields from results for legacy-declared requests, `0.3.0` added the HTTP transport and the `ttlMs`/`cacheScope` fields `2026-07-28` requires on `tools/list`, `0.4.0` replaced the @@ -29,8 +29,9 @@ writes and moves `schema_version` to `2` (in the exported bytes), and requires a `resources` and `prompts` lists to hold the package's structs (the host contract), and `0.6.0` names the canonical envelope's algorithm in its bytes and moves `schema_version` to `3` (in the exported bytes), each with a how-to-tell sentence in the changelog (`0.7.0` is an addition at -the minor, the signer seam, not a break). `~> 0.7` admits `0.8.0`, so it -would carry you across the next such break on a routine `mix deps.update`; `~> 0.7.0` does not. The tighter form is deliberate and is not an over-pin to be tidied away. What the pin +the minor, the signer seam, and `0.8.0` a quiet minor in which no public entry moved; neither +a break). `~> 0.8` would admit a `0.9.0`, so it +would carry you across the next such break on a routine `mix deps.update`; `~> 0.8.0` does not. The tighter form is deliberate and is not an over-pin to be tidied away. What the pin buys is written down: [`docs/api-stability.md`](docs/api-stability.md) says what is public (what ex_doc lists, `docs/public-api.txt` line by line), how a deprecation runs (three steps, three minors), and what a `0.x` break must say; a census holds the surface to that record. @@ -528,7 +529,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 fifteen steps a contributor with Elixir +pinned) and never in the local gate, which stays the sixteen 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 diff --git a/SECURITY.md b/SECURITY.md index 976f5a7..c1ddd0f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -104,7 +104,8 @@ entry says so when it does. | version | supported | |---|---| -| `0.7.x` | yes | +| `0.8.x` | yes | +| `0.7.x` | no — superseded | | `0.6.x` | no — superseded | | `0.5.x` | no — superseded | | `0.4.x` | no — superseded | diff --git a/UPGRADING.md b/UPGRADING.md index 0a2f9f6..ec79d9c 100644 --- a/UPGRADING.md +++ b/UPGRADING.md @@ -13,8 +13,8 @@ says **BREAKING**, which the census requires. ## The rule for `0.x` -Breaks land at the **minor** position and nowhere else. Pin `~> 0.7.0` (the current minor, -three numbers), not `~> 0.7`: the tighter pin stops at the next minor, which is where the +Breaks land at the **minor** position and nowhere else. Pin `~> 0.8.0` (the current minor, +three numbers), not `~> 0.8`: the tighter pin stops at the next minor, which is where the next documented break can be, so a routine `mix deps.update` never carries you across one. To move a minor: read the release's rows below and their CHANGELOG entries, apply each "how to tell" sentence to your host, then raise the pin. @@ -41,18 +41,30 @@ one that does adds the separate package `beam_mcp_signer` and passes its module `signature/3`. Raise the pin to `~> 0.7.0` when you take it; `~> 0.6.0` stops before it by the rule, not because anything moved. +**`0.8.0` has no row either: it is the quiet minor.** No public entry was added, removed, +renamed, hidden or changed in arity — `docs/public-api.txt` is line for line `0.7.0`'s, and the +release step wrote nothing into it — and no wire byte or envelope byte moved. What changed is +instruments (the gate diffs the baseline against `origin/main`; the pull-request summary waits +for running legs) and pages (the Scorecard's measured figures on the governance page). Raise +the pin to `~> 0.8.0`. **A host on `beam_mcp_signer` waits for that package's release that +admits `0.8.0`**: its `0.1.0` requires `beam_mcp ~> 0.7.0` (three numbers, by the same rule as +this page's pin), so `{:beam_mcp, "~> 0.8.0"}` beside `{:beam_mcp_signer, "~> 0.1.0"}` does not +resolve until it does. That is the cost of the three-number pin, paid once per minor, on the +signer's side. + ## The road to `1.0.0`, in order Stated here so nobody infers it from a plan's label or a folder's name: 1. **`0.6.0`** — everything since `0.5.0`, the assessability snapshot. One documented break at the minor (the exported bytes). -2. **`0.7.0`** — this release: the signer seam, `BeamMCP.Signer` (a behaviour added to the - public surface; no authority passes through it), the last intentional addition before - `1.0.0`. An addition, not a break; `~> 0.6.0` stops at it all the same, by the rule. -3. **`0.8.0`** — a quiet minor: documentation, the Scorecard's rows, instrument leftovers. No - public entry added, removed, renamed or hidden — the "full minor release unchanged" the - README's `1.0.0` condition requires, measured by `docs/public-api.txt` not moving. +2. **`0.7.0`** — the signer seam, `BeamMCP.Signer` (a behaviour added to the public + surface; no authority passes through it), the last intentional addition before `1.0.0`. + An addition, not a break; `~> 0.6.0` stops at it all the same, by the rule. +3. **`0.8.0`** — this release: the quiet minor. Documentation, the Scorecard's rows, + instrument leftovers. No public entry added, removed, renamed or hidden — the "full minor + release unchanged" the README's `1.0.0` condition requires, measured by + `docs/public-api.txt` not moving (the gate now diffs it against `origin/main` on every run). 4. **`1.0.0`** — after `0.8.0` has stood: the surface frozen as `docs/api-stability.md` says. ## What `1.0` will ask diff --git a/docs/api-stability.md b/docs/api-stability.md index 395e778..66ac40a 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -44,7 +44,7 @@ removes a callable arity and is a change to the entry — then markers: `R` is a release number, or the word `Unreleased` while the change waits in the CHANGELOG's Unreleased section; the release that ships it writes its number in -(`MIX_ENV=test mix run -e 'BeamMCP.PublicAPI.release_markers!("0.7.0")'`, one step of cutting +(`MIX_ENV=test mix run -e 'BeamMCP.PublicAPI.release_markers!("0.8.0")'`, one step of cutting a release), and the census refuses a leftover `Unreleased` once that section is empty. That step is the one place that knows the release's number, so it holds the three rules the census cannot: with a `removed_in=Unreleased` line in the file it refuses a patch number, and on @@ -83,7 +83,7 @@ From the release this page ships in, a break is a CHANGELOG heading carrying the census holds the Unreleased section to that — and `UPGRADING.md` lists them. Earlier releases documented their breaks under headings of their own wording (`0.2.0`'s "two fields are REMOVED", `0.4.0`'s "BREAKING, and it breaks a host contract"); `UPGRADING.md` names each with -its CHANGELOG heading. That is why the README recommends the current minor at three numbers (`~> 0.7.0`) rather than two (`~> 0.7`): the +its CHANGELOG heading. That is why the README recommends the current minor at three numbers (`~> 0.8.0`) rather than two (`~> 0.8`): the tighter pin stops at the next minor, which is where the next documented break can be. A patch release carries no break to the public surface, the wire, the exported bytes or the host contract — a rule the census cannot check (it does not know which number the next release diff --git a/docs/connectome-canonical.md b/docs/connectome-canonical.md index 5d529cd..f6b3c53 100644 --- a/docs/connectome-canonical.md +++ b/docs/connectome-canonical.md @@ -140,7 +140,7 @@ byte but that value. the envelope. **`1`** (0.4.0): the sign values were `allow`, `deny`, `hold`, `unknown`. **`2`** (0.5.0): `unset` — no sign was supplied to the package that wrote the bytes — and `ungoverned`, a consumer's affirmative "no rule of my policy applies", replace `unknown`; -nothing else moved. **`3`** (`0.6.0`, which carries this note): the envelope +nothing else moved. **`3`** (`0.6.0`): the envelope names its algorithm as a fourth top-level member, `"algorithm"`, between the version and the nodes (rule 1), and the hash is that digest over the bytes (rule 9). The vocabulary is `2`'s, unchanged. **At `1`, `unknown` covers both of `2`'s new signs**: a 0.4.0 consumer that looked diff --git a/docs/governance.md b/docs/governance.md index 48ba67e..c9c25cf 100644 --- a/docs/governance.md +++ b/docs/governance.md @@ -27,10 +27,11 @@ 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 fifteen steps locally and in CI — format, +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 properties, the optional-dependency probe, the dependency audit, the benchmarks, the docs, - REUSE, the licence files, the publication census and the commit-message terms — on three + 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 non-zero count is a failure. 3. **Review by tier, then the merge word.** `CONVENTIONS.md` states the tier rule: a contract @@ -54,24 +55,30 @@ its result. It is a measurement of the tree and the platform, and this page says checks this project keeps on purpose, which it cannot, and which it has decided against — so a reader does not have to guess whether a low mark is neglect or a decision. -| check | this repository | why | -| --- | --- | --- | -| Pinned-Dependencies | every workflow action pinned by commit SHA with its version beside it; Mix dependencies locked in `mix.lock` | a tag can be moved, a SHA cannot; a test holds the pins on every push | -| Token-Permissions | every workflow declares top-level `permissions:` with no write; the two jobs that write (provenance's attestation, the Scorecard's SARIF upload) hold it at the job, and no other job does | least privilege, held by the same test — the top level, and which jobs may write | -| Branch-Protection | the ruleset above: no direct push, linear history, required checks | kept; **no required reviewer** — see Code-Review | -| Code-Review | pull requests, every one since the ruleset (2026-09-06; the eight bootstrap commits before it were pushed directly); the reviewer of record is the maintainer, after the lanes | one maintainer cannot approve their own pull request under GitHub's rules, and there is no second one; the Scorecard will score this low and that is the true state, not an omission | -| Security-Policy | `SECURITY.md` | the intake, the rubric and the CVE path | -| License | `LICENSE`, `NOTICE`, `LICENSES/`, REUSE headers on every file, held by the gate | | -| Dependency-Update-Tool | Dependabot, weekly, Mix and GitHub Actions | | -| Vulnerabilities | `mix hex.audit` in the gate, OSV-fed, on every push | | -| CI-Tests | the gate on three OTP/Elixir pairs | | -| Maintained | commits and releases as the CHANGELOG shows | | -| 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 | -| SAST | Dialyzer and Credo in the gate; no CodeQL | the gate's analysers are what the language has; a CodeQL workflow is a separate decision and is not taken here | -| Fuzzing | eleven property-based tests in the gate; no OSS-Fuzz | property tests are the fuzzing the suite does; OSS-Fuzz integration is not taken | -| Dangerous-Workflow | the pull-request body is read through an environment variable, never interpolated into a script | | -| Binary-Artifacts | none in the tree | | -| Contributors | one organization | | +The figures are the Scorecard's own, read from `api.scorecard.dev` on 2026-09-19 (aggregate +**7**), and say what the check measured beside what the tree holds; a figure is quoted, not +promised, and moves when the Scorecard next runs. + +| check | this repository | why | measured 2026-09-19 | +| --- | --- | --- | --- | +| Pinned-Dependencies | every workflow action pinned by commit SHA with its version beside it; Mix dependencies locked in `mix.lock` | a tag can be moved, a SHA cannot; a test holds the pins on every push | 10 — "all dependencies are pinned" | +| Token-Permissions | every workflow declares top-level `permissions:` with no write; the two jobs that write (provenance's attestation, the Scorecard's SARIF upload) hold it at the job, and no other job does | least privilege, held by the same test — the top level, and which jobs may write | 10 | +| Branch-Protection | the ruleset above: no direct push, linear history, required checks | kept; **no required reviewer** — see Code-Review | 4 — "not maximal": the missing tiers are the required reviewer and a second approver, which one maintainer cannot supply | +| Code-Review | pull requests, every one since the ruleset (2026-09-06; the eight bootstrap commits before it were pushed directly); the reviewer of record is the maintainer, after the lanes | one maintainer cannot approve their own pull request under GitHub's rules, and there is no second one; the Scorecard scores this low and that is the true state, not an omission | 0 — "0/7 approved changesets" | +| Security-Policy | `SECURITY.md` | the intake, the rubric and the CVE path | 10 | +| 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" | +| 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 | +| SAST | Dialyzer and Credo in the gate; no CodeQL | the gate's analysers are what the language has; a CodeQL workflow is a separate decision and is not taken here | 0 — the check recognises neither Dialyzer nor Credo | +| Fuzzing | eleven property-based tests in the gate; no OSS-Fuzz | property tests are the fuzzing the suite does; OSS-Fuzz integration is not taken | 10 — "project is fuzzed": the check reads the property tests as fuzzing | +| CII-Best-Practices | no OpenSSF Best Practices badge | registration is the maintainer's, and open | 0 | +| Dangerous-Workflow | the pull-request body is read through an environment variable, never interpolated into a script | | 10 | +| Binary-Artifacts | none in the tree | | 10 | +| Contributors | one organization owns the repository; the NOTICE names the owner and the builder | the check counts the companies commit authors declare, and read two | 6 — "2 contributing companies or organizations" | ## What is deliberately not done diff --git a/docs/provenance.md b/docs/provenance.md index 4ae1a5a..00e76a4 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -32,7 +32,7 @@ So the release tarball is built by one script on every seat — CI, the publishe reproducing it: ```sh -tools/release_tarball.sh v0.6.0 beam_mcp-0.6.0.tar +tools/release_tarball.sh v0.7.0 beam_mcp-0.7.0.tar ``` It `git archive`s the ref with `tar.umask=022` (every file `644` whatever the machine's umask, @@ -53,7 +53,7 @@ failure, not as a pass. ## Verify a published tarball ```sh -v=0.6.0 +v=0.7.0 curl -fsSLO "https://repo.hex.pm/tarballs/beam_mcp-${v}.tar" gh attestation verify "beam_mcp-${v}.tar" --repo ScriptKittyOS/beam_mcp ``` diff --git a/mix.exs b/mix.exs index a815ddf..d043a0b 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule BeamMCP.MixProject do use Mix.Project - @version "0.7.0" + @version "0.8.0" @source_url "https://github.com/ScriptKittyOS/beam_mcp" # The oldest OTP this project supports. Mix has an `elixir:` key but none for OTP, so the diff --git a/test/beam_mcp/boundary/no_signature_test.exs b/test/beam_mcp/boundary/no_signature_test.exs index 0a82655..15d7e3d 100644 --- a/test/beam_mcp/boundary/no_signature_test.exs +++ b/test/beam_mcp/boundary/no_signature_test.exs @@ -99,7 +99,7 @@ defmodule BeamMCP.Boundary.NoSignatureTest do {:ok, callbacks} = Code.Typespec.fetch_callbacks(BeamMCP.Signer) assert [{{:sign, 2}, [spec]}] = callbacks - {:type, _, :fun, [{:type, _, :product, args}, ret]} = spec + {:type, _, :fun, [{:type, _, :product, args}, _ret]} = spec names = Enum.map(args, fn diff --git a/test/beam_mcp/readme_claims_test.exs b/test/beam_mcp/readme_claims_test.exs index 5cf3207..7882bdd 100644 --- a/test/beam_mcp/readme_claims_test.exs +++ b/test/beam_mcp/readme_claims_test.exs @@ -137,7 +137,7 @@ defmodule BeamMCP.ReadmeClaimsTest do describe "the dependency requirement the README hands a consumer" do test "it does not span the wire break this release documents" do - requirement = "~> 0.7.0" + requirement = "~> 0.8.0" claims("{:beam_mcp, \"#{requirement}\"}") version = Mix.Project.config()[:version] @@ -145,11 +145,14 @@ defmodule BeamMCP.ReadmeClaimsTest do assert Version.match?(version, requirement), "the README's requirement must admit the version being shipped" - refute Version.match?("0.6.0", requirement), - "0.6.0 is the minor before this one; 0.7.0 adds the signer seam and breaks " <> - "nothing, and the pin still stops at the current minor by the 0.x rule -- " <> + refute Version.match?("0.7.0", requirement), + "0.7.0 is the minor before this one; 0.8.0 is a quiet minor that moves no public " <> + "entry, and the pin still stops at the current minor by the 0.x rule -- " <> "the next minor is where the next documented break can be." + refute Version.match?("0.6.0", requirement), + "0.6.0 is two minors back; 0.7.0 added the signer seam and broke nothing." + refute Version.match?("0.5.0", requirement), "0.5.0 is on the far side of the break 0.6.0 documented in the exported " <> "bytes: a verifier that pins schema_version 2, or hashes without reading the " <> diff --git a/test/fixtures/wire/pre-017.json b/test/fixtures/wire/pre-017.json index 2ba254d..fd03254 100644 --- a/test/fixtures/wire/pre-017.json +++ b/test/fixtures/wire/pre-017.json @@ -7,7 +7,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -27,7 +27,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -50,7 +50,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -71,7 +71,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -95,7 +95,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -116,7 +116,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -140,7 +140,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -171,7 +171,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -204,7 +204,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", @@ -251,7 +251,7 @@ "_meta": { "io.modelcontextprotocol/serverInfo": { "name": "fixture", - "version": "0.7.0" + "version": "0.8.0" } }, "cacheScope": "private", diff --git a/tools/baseline_diff.sh b/tools/baseline_diff.sh new file mode 100755 index 0000000..ecdc5df --- /dev/null +++ b/tools/baseline_diff.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# The two hand edits to docs/public-api.txt the census cannot see, because it reads the tree +# alone (G-076, measured by a review lane: the whole gate green on each): +# +# 1. a baseline line deleted outright -- the writer never deletes a line, so a non-comment +# line present at the base and absent at the head, whose entry (the text before its +# markers) does not reappear, is a hand deletion of a public entry; +# 2. a marker back-dated -- a `since=`, `deprecated_since=` or `removed_in=` value ARRIVING +# in this change as a release number that is not above the base CHANGELOG's highest +# `## [N]` heading: one it already lists, or a phantom between two releases (a lane +# measured `removed_in=0.6.5` passing the first draft) -- a change dressed as one that +# already shipped. The release commit writes its own, higher number, and passes. +# +# Pure: three files in, a verdict out, so a probe can plant on copies. The gate feeds it the +# base from `git show origin/main:...` and the head from the tree. +# +# tools/baseline_diff.sh +# exit 0: neither edit; 1: one or both (each named); 2: usage. +set -uo pipefail +[ $# -eq 3 ] || { echo "usage: tools/baseline_diff.sh " >&2; exit 2; } +base=$1 head=$2 changelog=$3 +for f in "$base" "$head" "$changelog"; do [ -r "$f" ] || { echo "unreadable: $f" >&2; exit 2; }; done + +entries() { grep -v '^#' "$1" | grep -v '^[[:space:]]*$' | sed -E 's/( (since|deprecated_since|removed_in)=[^ ]+)+$//' | sort -u; } +# Per entry, so a NEW line arriving with a number an old line already carries is still an arrival. +markers() { grep -v '^#' "$1" | awk '{for (i=2;i<=NF;i++) if ($i ~ /^(since|deprecated_since|removed_in)=/) print $1" "$2" "$3" "$i}' | sort -u; } + +fail=0 +# 1. Deleted entries. +deleted=$(comm -23 <(entries "$base") <(entries "$head")) +if [ -n "$deleted" ]; then + echo "FAIL: public entries deleted from the baseline by hand (the writer never deletes a line; a removal is marked removed_in= and stays):" + printf '%s\n' "$deleted" | sed 's/^/ /' + fail=1 +fi +# 2. Markers arriving with an already-released number. +highest=$(grep -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' "$changelog" | tr -d '#[] ' | sort -V | tail -1) +arriving=$(comm -13 <(markers "$base") <(markers "$head")) +backdated="" +while IFS= read -r m; do + [ -n "$m" ] || continue + v=${m##*=} + [ "$v" = "Unreleased" ] && continue + # Not above the highest released heading: equal to it, below it, or between two releases. + if [ -z "$highest" ] || [ "$v" = "$highest" ] || [ "$(printf '%s\n%s\n' "$highest" "$v" | sort -V | tail -1)" != "$v" ]; then + backdated="${backdated}${m}"$'\n' + fi +done <<< "$arriving" +if [ -n "$backdated" ]; then + echo "FAIL: markers arriving with a release number not above the base CHANGELOG's highest heading ($highest) -- a change dressed as one that shipped:" + printf '%s' "$backdated" | sort -u | sed 's/^/ /' + fail=1 +fi +if [ "$fail" -eq 0 ]; then + echo "pass ($(entries "$head" | wc -l | tr -d ' ') entries at the head, none deleted; $(printf '%s\n' "$arriving" | grep -c . || true) marker(s) arriving, none back-dated)" +fi +exit "$fail" diff --git a/tools/ci_legs_verdict.sh b/tools/ci_legs_verdict.sh new file mode 100755 index 0000000..cb3623a --- /dev/null +++ b/tools/ci_legs_verdict.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# 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 +# 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 +# env: LEGS_MIN (default 3), 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 " >&2; exit 2; } +repo=$1 sha=$2 +min=${LEGS_MIN:-3} 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)"' +} +i=0 +while :; do + # A failed read is a poll spent, not a verdict: a transient API error during the wait must + # not fail the required check (a red run of this name blocks beside a later green). + if legs=$(read_legs); then read_ok=1; else read_ok=0; legs=""; fi + if [ -n "$legs" ]; then + n=$(printf '%s\n' "$legs" | grep -c . || true) + pending=$(printf '%s\n' "$legs" | grep -vc ': completed/' || true) + else + n=0; pending=0 + fi + if [ "$read_ok" -eq 1 ] && [ "$n" -ge "$min" ] && [ "$pending" -eq 0 ]; then break; fi + i=$((i + 1)) + if [ "$i" -gt "$limit" ]; then + printf '%s\n' "$legs" + echo "FAIL: after $((limit * every)) s, $n leg verdict(s) exist for ${sha} and $pending are not completed (last read $([ "$read_ok" -eq 1 ] && echo ok || echo failed)); the tree is unmeasured" + exit 1 + fi + if [ "$read_ok" -eq 1 ]; then + echo "waiting: $n leg check-run(s), $pending not completed (poll $i of $limit, every ${every}s)" + else + echo "waiting: the checks API could not be read (poll $i of $limit, every ${every}s)" + fi + sleep "$every" +done +printf '%s\n' "$legs" +bad=$(printf '%s\n' "$legs" | grep -vc ': completed/success$' || true) +[ "$bad" -eq 0 ] || { echo "FAIL: a leg's latest verdict for ${sha} is not success"; exit 1; } +echo "pass: $n leg(s) completed, all success, for ${sha}" diff --git a/tools/gate.sh b/tools/gate.sh index 7308928..7dfb3a9 100755 --- a/tools/gate.sh +++ b/tools/gate.sh @@ -469,6 +469,26 @@ else fail=1 fi +# The public-API baseline against origin/main: no entry's line deleted by hand, no marker +# arriving with a number the CHANGELOG already lists (G-076: the census reads the tree alone, +# so both edits passed it green; tools/baseline_diff.sh is the pure check, tools/probe_baseline_diff.sh +# its probe). Where origin/main does not resolve the line says so and is not evidence. +if git cat-file -e origin/main:docs/public-api.txt 2>/dev/null; then + bl_dir=$(mktemp -d "${TMPDIR:-/tmp}/beam_mcp-gate-baseline.XXXXXX") + git show origin/main:docs/public-api.txt > "$bl_dir/base.txt" + git show origin/main:CHANGELOG.md > "$bl_dir/changelog.md" + if bl_out=$(bash tools/baseline_diff.sh "$bl_dir/base.txt" docs/public-api.txt "$bl_dir/changelog.md" 2>&1); then + note "baseline" "$bl_out" + else + note "baseline" "FAIL (docs/public-api.txt against origin/main)" + printf '%s\n' "$bl_out" | sed 's/^/ /' + fail=1 + fi + rm -rf "$bl_dir" +else + note "baseline" "pass (origin/main does not resolve here; the baseline was not diffed -- this line is not evidence)" +fi + # Commit messages: the branch's own commits carry no attribution trailer, no session link, no # board identifier and no consumer name. # diff --git a/tools/probe_baseline_diff.sh b/tools/probe_baseline_diff.sh new file mode 100755 index 0000000..b2a7482 --- /dev/null +++ b/tools/probe_baseline_diff.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Probe: does tools/baseline_diff.sh go red on the two hand edits G-076 names -- a public +# entry's line deleted outright, and a marker arriving with a number the CHANGELOG already +# lists -- and stay green on the edits the writer and the release step make? Plants are on +# copies in a temp dir; the tree is not touched. +# +# tools/probe_baseline_diff.sh +set -uo pipefail +here=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d "${TMPDIR:-/tmp}/beam_mcp-probe-baseline.XXXXXX"); trap 'rm -rf "$work"' EXIT +cp "$here/docs/public-api.txt" "$work/base.txt" +cp "$here/CHANGELOG.md" "$work/changelog.md" +released=$(grep -oE '^## \[[0-9]+\.[0-9]+\.[0-9]+\]' "$work/changelog.md" | head -1 | tr -d '#[] ') +[ -n "$released" ] || { echo "PROBE NOT MEASURED: no released heading in CHANGELOG.md"; exit 2; } +entry=$(grep -v '^#' "$work/base.txt" | grep -m1 -v 'since=' || true) +[ -n "$entry" ] || { echo "PROBE NOT MEASURED: no unmarked entry in the baseline to plant on"; exit 2; } +bad=0 +run() { # run + out=$(bash "$here/tools/baseline_diff.sh" "$work/base.txt" "$3" "$work/changelog.md" 2>&1); rc=$? + printf ' %-58s exit %s (want %s): %s\n' "$1" "$rc" "$2" "$(printf '%s\n' "$out" | head -1 | cut -c1-70)" + [ "$rc" -eq "$2" ] || bad=1 +} +cp "$work/base.txt" "$work/same.txt"; run "P0 unchanged" 0 "$work/same.txt" +grep -vxF "$entry" "$work/base.txt" > "$work/deleted.txt"; run "P1 an entry's line deleted outright" 1 "$work/deleted.txt" +{ cat "$work/base.txt"; echo "$entry"; } | awk -v e="$entry" '$0==e && !s {print e" removed_in=Unreleased"; s=1; next} $0!=e' > "$work/marked.txt" + run "P2 the same entry marked removed_in=Unreleased" 0 "$work/marked.txt" +sed "s|^$entry\$|$entry removed_in=$released|" "$work/base.txt" > "$work/backdated.txt" + run "P3 removed_in= with the last release's number" 1 "$work/backdated.txt" +sed "s|^$entry\$|$entry deprecated_since=$released|" "$work/base.txt" > "$work/backdated2.txt" + run "P4 deprecated_since= with the last release's number" 1 "$work/backdated2.txt" +sed "s|^$entry\$|$entry since=9.9.9|" "$work/base.txt" > "$work/release.txt" + run "P5 a marker with a number not yet released (the release step)" 0 "$work/release.txt" +{ cat "$work/base.txt"; echo "BeamMCP.Probe function added/1 since=Unreleased"; } > "$work/added.txt" + run "P6 a new entry marked Unreleased (the writer)" 0 "$work/added.txt" +{ cat "$work/base.txt"; echo "BeamMCP.Probe function added/1 since=$released"; } > "$work/added-backdated.txt" + run "P7 a new entry marked since= the last release (back-dated addition)" 1 "$work/added-backdated.txt" +sed "s|^$entry\$|$entry removed_in=0.0.1|" "$work/base.txt" > "$work/phantom.txt" + run "P8 removed_in= with a phantom number below the last release" 1 "$work/phantom.txt" +if [ "$bad" -eq 0 ]; then echo "PROBE OK: the two hand edits are red; the writer's and the release step's edits are green"; else echo "PROBE FAILED"; exit 1; fi diff --git a/tools/probe_ci_legs_verdict.sh b/tools/probe_ci_legs_verdict.sh new file mode 100755 index 0000000..37c03f1 --- /dev/null +++ b/tools/probe_ci_legs_verdict.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Sudo Apt Holdings LLC +# SPDX-License-Identifier: Apache-2.0 +# +# Probe: does tools/ci_legs_verdict.sh wait for legs still running and then judge them, fail a +# red leg, fail too few legs, and fail at its bound -- with a fake `gh` on PATH that answers a +# scripted sequence of check-run pages (G-079's in_progress-then-success case first)? Nothing +# reaches GitHub; the probe is offline. +# +# tools/probe_ci_legs_verdict.sh +set -uo pipefail +here=$(cd "$(dirname "$0")/.." && pwd) +work=$(mktemp -d "${TMPDIR:-/tmp}/beam_mcp-probe-legs.XXXXXX"); trap 'rm -rf "$work"' EXIT +mkdir -p "$work/bin" +# The fake gh: prints the JSON page named by a counter file, advancing one per call, holding +# the last. It ignores --jq and applies the real jq filter itself, so the script's filter is +# the one exercised. +cat > "$work/bin/gh" <<'GH' +#!/usr/bin/env bash +seq_dir=$PROBE_SEQ; n=$(cat "$seq_dir/n"); pages=$(ls "$seq_dir"/page.* | wc -l) +[ "$n" -lt "$pages" ] && echo $((n + 1)) > "$seq_dir/n" +page="$seq_dir/page.$n"; [ -f "$page" ] || page="$seq_dir/page.$((pages - 1))" +filter=""; while [ $# -gt 0 ]; do [ "$1" = "--jq" ] && filter=$2; shift; done +grep -q '"__fail__"' "$page" && { echo "gh: HTTP 502 (fake)" >&2; exit 1; } +jq -r "$filter" < "$page" +GH +chmod +x "$work/bin/gh" +command -v jq >/dev/null || { echo "PROBE NOT MEASURED: jq is not installed"; exit 2; } +leg() { printf '{"name":"Gate - leg (OTP %s, Elixir %s, %s)","status":"%s","conclusion":%s}' "$1" "$2" "$3" "$4" "$5"; } +page() { printf '{"check_runs":[%s]}' "$(IFS=,; echo "$*")"; } +skipped='{"name":"Gate - leg (OTP ${{ matrix.otp }}, Elixir ${{ matrix.elixir }}, ${{ matrix.leg }})","status":"completed","conclusion":"skipped"}' +bad=0 +run() { # run + echo 0 > "$3/n" + out=$(PATH="$work/bin:$PATH" PROBE_SEQ=$3 POLL_SECONDS=0 POLL_LIMIT=3 bash "$here/tools/ci_legs_verdict.sh" o/r deadbeef 2>&1); rc=$? + printf ' %-52s exit %s (want %s); pages advanced %s; last: %s\n' "$1" "$rc" "$2" "$(cat "$3/n")" "$(printf '%s\n' "$out" | tail -1 | cut -c1-60)" + [ "$rc" -eq "$2" ] || bad=1 +} +# S1: G-079 -- two polls in progress (conclusion null), then all success. +d=$work/s1; mkdir -p "$d" +page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned in_progress null)" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.0" +page "$(leg 27 1.17 floor completed '"success"')" "$(leg 28 1.18 pinned in_progress null)" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.1" +page "$(leg 27 1.17 floor completed '"success"')" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.2" +run "S1 in_progress twice, then three successes" 0 "$d" +# S2: a leg red once complete. +d=$work/s2; mkdir -p "$d" +page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" > "$d/page.0" +page "$(leg 27 1.17 floor completed '"failure"')" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" > "$d/page.1" +run "S2 a leg completes as failure" 1 "$d" +# S3: a leg never completes -- the bound. +d=$work/s3; mkdir -p "$d" +page "$(leg 27 1.17 floor in_progress null)" "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" > "$d/page.0" +run "S3 a leg never completes (bound of 3 polls)" 1 "$d" +# S4: only two legs exist, both green -- the tree is unmeasured on one pair. +d=$work/s4; mkdir -p "$d" +page "$(leg 28 1.18 pinned completed '"success"')" "$(leg 29 1.20 head completed '"success"')" "$skipped" > "$d/page.0" +run "S4 two legs only, the skipped unexpanded name beside" 1 "$d" +# S5: all three complete and green on the first read -- no wait. +d=$work/s5; mkdir -p "$d" +cp "$work/s1/page.2" "$d/page.0" +run "S5 three successes on the first read" 0 "$d" +# S6: the API fails once, then three successes -- a read spent, not a verdict. +d=$work/s6; mkdir -p "$d" +echo '{"__fail__":true}' > "$d/page.0"; cp "$work/s1/page.2" "$d/page.1" +run "S6 the API fails once, then three successes" 0 "$d" +# S7: the API fails every time -- the bound, named as a failed read. +d=$work/s7; mkdir -p "$d" +echo '{"__fail__":true}' > "$d/page.0" +run "S7 the API fails every time (bound)" 1 "$d" +# S8: an empty page -- no check runs at all; the count says 0, not 1. +d=$work/s8; mkdir -p "$d" +echo '{"check_runs":[]}' > "$d/page.0" +run "S8 no check runs at all" 1 "$d" +if [ "$bad" -eq 0 ]; then echo "PROBE OK: the read waits for running legs, then judges; red, too few, and the bound are FAIL"; else echo "PROBE FAILED"; exit 1; fi diff --git a/tools/probe_gate_honesty.sh b/tools/probe_gate_honesty.sh index 4ecc277..1a37bd7 100755 --- a/tools/probe_gate_honesty.sh +++ b/tools/probe_gate_honesty.sh @@ -93,7 +93,9 @@ run_gate() { # run_gate printf ' licence files line: %s\n' "$(grep -E '^ licence files ' "$W/gate.out" | sed 's/^ *//' || true)" printf ' licence files line count: %s (0 means the step printed NOTHING)\n' \ "$(grep -c '^ licence files ' "$W/gate.out")" - other=$(grep -E '^ (format|compile|instruments|test|credo|properties|optional deps|bench|docs|reuse|licence files|publication|messages) ' "$W/gate.out" | grep -cv ' pass$') + # A step line ends in `pass` or `pass ()`; the earlier `' pass$'` counted every + # detailed line as not-pass (G-078: 7 on every probe, P0 included). + other=$(grep -E '^ (format|compile|dialyzer|instruments|test|credo|properties|optional deps|audit|bench|docs|reuse|licence files|publication|baseline|messages) ' "$W/gate.out" | grep -cvE ' pass( \(|$)') printf ' other steps not pass: %s (so the objection above is this step, not a side effect)\n' "$other" printf ' GATE_EXIT=%s\n' "$rc" grep -E '^ +(probe|PROBE)[^ ]*$' "$W/gate.out" | sed 's/^ */ named by the step: /'