From 9399171593d6a52e19a9a5d228f5a4a7952e2a74 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 23 Sep 2026 06:22:18 +0200 Subject: [PATCH 1/5] feat(musl): publish a static musl Linux asset, and prove it builds on every PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RQ-71-MUSL (#1349). Both refuting commands were re-run first and BOTH confirmed the premise: * the v0.70.0 x86_64 asset, downloaded and measured with the issue's own two commands — dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, max of 17 GLIBC_ symbols = GLIBC_2.39. The runner image did not move under us; * `grep musl .github/workflows/*.yml` — one hit, in fuzz-smoke.yml, and it is there to FORCE the GNU target. No musl release target existed. 2.39 excludes Ubuntu 22.04, Debian 12, RHEL 9, Amazon Linux 2023, Ubuntu 20.04 and Alpine entirely. These feed the varve `pulseengine` rolling layer, whose portability is the MAXIMUM floor across its payloads — one payload sets it for everything pinned beside it. `x86_64-unknown-linux-musl`, statically linked: no loader, no glibc floor at all rather than a lower one. ADDITIVE — the gnu assets keep their names, consumers and attestation path, so nothing that works today changes. THE PART THAT IS NOT PLUMBING. release.yml runs only on a TAG PUSH, so a target added there is unverified until the tag, and editing the release workflow between an RC and its tag is the unreviewed late change this process exists to prevent. So a per-PR job (`musl-portable-asset`) builds the same target with the same feature set and runs the issue's two acceptance commands on the result: statically linked, no interpreter, ZERO GLIBC_ symbols. WITH A POTENCY CONTROL, because those assertions could pass on anything: the same job builds the GNU target on the same runner and asserts they FAIL there. A FALSE DOC STATEMENT FOUND ON THE WAY, corrected in place. `docs/release-process.md` said the `verify` feature is "not" enabled in release builds and that enabling it "remains a deliberate follow-up decision". It was taken in v0.58 (RQ-58-SHIPVERIFY #1000/#1002, which put `--features verify` on both build lines), so the sentence had been false for thirteen releases. Measured on the shipped v0.70.0 asset: 20 `ordeal` strings, ZERO occurrences of the degraded-path message. RESIDUAL, NAMED: arm64 Linux is still glibc-floored. Only the x86_64 musl asset is published; aarch64-unknown-linux-musl needs the cross path and its own measurement, and is not claimed. Refs #1349 --- .github/workflows/ci.yml | 67 ++++++++++++++ .github/workflows/release.yml | 51 +++++++++++ artifacts/release-v0.71/RQ-71-MUSL.yaml | 114 ++++++++++++++---------- docs/release-process.md | 76 ++++++++++++++-- 4 files changed, 254 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dfa2413..bbedd6b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5751,3 +5751,70 @@ jobs: set -euo pipefail python3 scripts/oracle_evidence.py "$ORACLE_EVIDENCE_JSONL" --min-oracles 1 + + # RQ-71-MUSL (#1349): PROVE the portable release asset before promising it. + # + # `release.yml` only runs on a tag push, so a new target added there is + # UNVERIFIED until the tag — and changing the release workflow between an RC + # and its tag is the unreviewed late change this process exists to prevent + # (v0.70 shipped the 2.39 floor rather than make one). This job builds the + # SAME target with the SAME feature set on every PR, and runs the issue's own + # two acceptance commands on the result, so the release.yml matrix entry is + # verified by construction rather than on the day it matters. + # + # MEASURED on the published v0.70.0 x86_64 asset: `ELF 64-bit LSB pie + # executable, dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2`, + # max GLIBC_ symbol GLIBC_2.39 — excluding Ubuntu 22.04 (2.35), Debian 12 + # (2.36), RHEL 9 / AL2023 (2.34), Ubuntu 20.04 (2.31) and Alpine entirely. + # These binaries feed the varve `pulseengine` realm's rolling layer, whose + # portability is the MAXIMUM floor across its payloads. + # + # Deliberately NOT a required context: it is new, and a required name that has + # never run deadlocks merges. It reddens the PR that breaks it, which is the + # job's whole purpose. + musl-portable-asset: + name: Portable musl asset builds and is static (#1349) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-unknown-linux-musl + - uses: Swatinem/rust-cache@v2 + with: + key: musl-portable + - name: Install musl toolchain + run: sudo apt-get update && sudo apt-get install -y musl-tools + # The SAME command release.yml runs for this target. If these diverge the + # job stops testing the thing it claims to test. + - name: Build synth for x86_64-unknown-linux-musl + run: cargo build --release --target x86_64-unknown-linux-musl -p synth-cli --features verify + - name: The issue's own two acceptance commands, on the built artifact + run: | + set -euo pipefail + BIN=target/x86_64-unknown-linux-musl/release/synth + file "$BIN" + file "$BIN" | grep -q 'statically linked' \ + || { echo "FAIL: not statically linked"; exit 1; } + if file "$BIN" | grep -q 'interpreter'; then + echo "FAIL: carries a dynamic loader"; exit 1 + fi + N="$(strings -a "$BIN" | grep -c '^GLIBC_' || true)" + echo "GLIBC_ version symbols: $N (want 0)" + [ "$N" = "0" ] || { echo "FAIL: references glibc versions"; exit 1; } + # POTENCY: the gnu build on this same runner MUST fail those assertions, + # or they prove nothing about staticness — they would pass on anything. + - name: Potency — the gnu target must FAIL the same assertions + run: | + set -euo pipefail + cargo build --release --target x86_64-unknown-linux-gnu -p synth-cli --features verify + BIN=target/x86_64-unknown-linux-gnu/release/synth + file "$BIN" + if file "$BIN" | grep -q 'statically linked'; then + echo "FAIL(potency): the gnu build is static, so the musl check is vacuous"; exit 1 + fi + N="$(strings -a "$BIN" | grep -c '^GLIBC_' || true)" + echo "gnu GLIBC_ version symbols: $N (want > 0)" + [ "$N" -gt 0 ] \ + || { echo "FAIL(potency): no GLIBC_ symbols in the gnu build either"; exit 1; } + echo "gnu floor: $(strings -a "$BIN" | grep -oE '^GLIBC_[0-9.]+' | sort -V | tail -1)" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d36283bd..884d2086 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,6 +55,28 @@ jobs: os: ubuntu-latest archive: tar.gz cross: true + # RQ-71-MUSL (#1349). The gnu assets above are built on ubuntu-latest + # and carry a GLIBC_2.39 floor — MEASURED on the published v0.70.0 + # x86_64 asset: `ELF 64-bit LSB pie executable, dynamically linked, + # interpreter /lib64/ld-linux-x86-64.so.2`, max GLIBC_ symbol + # GLIBC_2.39. That excludes Ubuntu 22.04 (2.35), Debian 12 (2.36), + # RHEL 9 and Amazon Linux 2023 (2.34), Ubuntu 20.04 (2.31), and + # Alpine/distroless-static entirely. These binaries are ingested into + # the varve `pulseengine` realm's rolling layer, and a layer's + # portability is the MAXIMUM floor across its payloads, so one 2.39 + # payload sets the floor for everything pinned beside it. + # + # This target is ADDITIVE on purpose: the gnu assets keep their + # names, their consumers and their attestation path unchanged, and + # the static musl build is published ALONGSIDE as the portable + # payload. Statically linked, so it has no loader and no glibc floor + # at all rather than a lower one. `--features verify` is pure Rust + # here (no build.rs, no build-dependencies anywhere in the + # workspace), which is why this needs no cross container. + - target: x86_64-unknown-linux-musl + os: ubuntu-latest + archive: tar.gz + musl: true # x86_64-apple-darwin cross-compiles on the arm64 macos-14 # runner — matches pulseengine/rivet and pulseengine/witness # (both Rust-workspace CLIs, the closest analogs to synth). @@ -77,6 +99,13 @@ jobs: with: key: release-${{ matrix.target }} + # RQ-71-MUSL (#1349): `musl-tools` supplies `musl-gcc`, the linker driver + # rustc invokes for this target. The workspace has no C dependencies and + # no build.rs, but the target's own libc.a still links through it. + - name: Install musl toolchain + if: matrix.musl + run: sudo apt-get update && sudo apt-get install -y musl-tools + - name: Install cross if: matrix.cross run: cargo install cross --git https://github.com/cross-rs/cross --locked @@ -89,6 +118,28 @@ jobs: if: matrix.cross run: cross build --release --target ${{ matrix.target }} -p synth-cli --features verify + # RQ-71-MUSL (#1349): the ISSUE'S OWN two acceptance commands, executed + # on the artifact this job just built, BEFORE it is packaged. A portable + # asset that is silently dynamic, or that picked up a glibc symbol, must + # redden the release rather than ship and be discovered by a consumer — + # which is exactly how the 2.39 floor was found in the first place. Run + # before `strip`, because stripping is what would hide the symbols. + - name: Verify the musl asset is static with no glibc floor + if: matrix.musl + run: | + set -euo pipefail + BIN="target/${{ matrix.target }}/release/synth" + file "$BIN" + file "$BIN" | grep -q 'statically linked' \ + || { echo "FAIL: musl asset is not statically linked"; exit 1; } + if file "$BIN" | grep -q 'interpreter'; then + echo "FAIL: musl asset carries a dynamic loader"; exit 1 + fi + N="$(strings -a "$BIN" | grep -c '^GLIBC_' || true)" + echo "GLIBC_ version symbols: $N (want 0)" + [ "$N" = "0" ] \ + || { echo "FAIL: musl asset references glibc versions"; exit 1; } + - name: Strip binary if: ${{ !matrix.cross }} run: strip "target/${{ matrix.target }}/release/synth" 2>/dev/null || true diff --git a/artifacts/release-v0.71/RQ-71-MUSL.yaml b/artifacts/release-v0.71/RQ-71-MUSL.yaml index ff096835..8d5f7e3c 100644 --- a/artifacts/release-v0.71/RQ-71-MUSL.yaml +++ b/artifacts/release-v0.71/RQ-71-MUSL.yaml @@ -1,62 +1,82 @@ artifacts: - id: RQ-71-MUSL type: system-req - title: "Linux release assets are glibc-2.39-only, so one payload sets the portability floor for the whole varve pinned layer" + title: "Linux release assets were glibc-2.39-only, so one payload set the portability floor for the whole varve pinned layer — a static musl asset now ships alongside" description: > - #1349, 2026-09-22. Measured on the v0.69.0 Linux x86_64 release asset, - downloaded from this repo: `ELF 64-bit LSB pie executable, dynamically - linked, interpreter /lib64/ld-linux-x86-64.so.2`, and - `strings -a synth | grep -oE 'GLIBC_[0-9.]+' | sort -V | tail -1` gives - **GLIBC_2.39**. - - That excludes Ubuntu 22.04 (2.35), Debian 12 bookworm (2.36), RHEL 9 and - Amazon Linux 2023 (2.34), Ubuntu 20.04 (2.31), and Alpine / distroless-static - entirely. - - WHY IT IS NOT MERELY A CONVENIENCE. These binaries are ingested into the varve - `pulseengine` realm's rolling layer — the pinned toolchain an air-gapped or - enterprise consumer installs. A layer's portability is the MAXIMUM floor - across its payloads, so a single 2.39 payload sets the floor for everything - pinned beside it. - - v0.70 SHIPS WITH THIS TOO, AND SAID SO. The issue arrived on the day of the - v0.70 cut. Changing the release workflow between an RC and its tag is exactly - the unreviewed late change this process exists to prevent, so v0.70's assets - carry the same 2.39 floor and its release report states that plainly rather - than leaving a consumer to discover it. - - REFUTING COMMANDS TO RUN BEFORE SCOPING, not after. First: inspect the - v0.70.0 asset the moment it publishes, with the same two commands — if the - floor is already lower, the runner image moved under us and the premise needs - re-measuring. Second: `grep -n 'musl' .github/workflows/*.yml` — measured at - the v0.70 cut, the only hit is `fuzz-smoke.yml`, so no musl RELEASE target - exists today; publishing an artifact that is already built would be a much - smaller lane than adding a target, and the two must not be confused. - - HONEST RISK: this is release engineering, not compiler work, and it will look - cheaper than it is. A second target means a second set of assets, a second - path through sigil attestation, and a varve layer decision about which - payload is canonical. Measure before promising. - - THE MACHINE'S HALF OF THIS IS `contains:.github/workflows/release.yml:musl`. The full obligation is: - a Linux release asset is published whose dynamic-loader floor is verified by the issue's own two commands (file + the GLIBC_ strings scan) to run on Ubuntu 22.04, Debian 12 and RHEL 9, or a musl-static asset is published alongside; and the release process documents which asset varve's layer pins - The predicate cannot express all of that — it checks that the artefact - EXISTS, not that it is right. It is still worth having: v0.70 measured - that four releases running declared 100% `manual:` done-when, which R3 - can never fire on, and printed that as though it were evaluation. - - status: proposed + #1349, 2026-09-22. Both refuting commands were run again on this tree + before anything was scoped, and BOTH CONFIRMED the premise rather than + narrowing it. + + FIRST: the v0.70.0 asset, the moment it published — if the floor had + already dropped, the runner image moved and the premise needed + re-measuring. It had not. Downloaded from this repo and measured with the + issue's own two commands: `ELF 64-bit LSB pie executable, dynamically + linked, interpreter /lib64/ld-linux-x86-64.so.2`, and the maximum of 17 + distinct `GLIBC_` version symbols is **GLIBC_2.39**. + + SECOND: `grep -n 'musl' .github/workflows/*.yml` — one hit, in + `fuzz-smoke.yml`, and it is there to FORCE THE GNU TARGET because + cargo-fuzz defaults to musl. So no musl release target existed, and this + is the larger of the two lanes the plan distinguished: adding a target, + not publishing one already built. + + A 2.39 floor excludes Ubuntu 22.04 (2.35), Debian 12 (2.36), RHEL 9 and + Amazon Linux 2023 (2.34), Ubuntu 20.04 (2.31), and Alpine / + distroless-static entirely. These binaries are ingested into the varve + `pulseengine` realm's rolling layer, and a layer's portability is the + MAXIMUM floor across its payloads, so one 2.39 payload sets the floor for + everything pinned beside it. + + WHAT SHIPPED. `x86_64-unknown-linux-musl`, statically linked — no loader + and no glibc floor at all, rather than a lower one. ADDITIVE on purpose: + the gnu assets keep their names, their consumers and their attestation + path unchanged, so nothing that works today changes. The workspace has no + `build.rs` and no `[build-dependencies]` anywhere, which is why this needs + no cross container. + + THE PART THAT IS NOT RELEASE PLUMBING, and the reason this lane is not as + cheap as it looks: `release.yml` runs ONLY on a tag push, so a target added + there is UNVERIFIED until the tag — and changing the release workflow + between an RC and its tag is precisely the unreviewed late change this + process exists to prevent (v0.70 shipped the 2.39 floor rather than make + one). So a per-PR CI job builds the SAME target with the SAME feature set + and runs the issue's own two acceptance commands on the result. The + release.yml entry is verified by construction, not on the day it matters. + + WITH A POTENCY CONTROL, because "is statically linked" and "has no GLIBC_ + symbols" are assertions that could pass on anything: the same job builds + the GNU target on the same runner and asserts those checks FAIL there. If + they ever pass on both, the musl check proves nothing and the job says so. + + A FALSE DOC STATEMENT FOUND ON THE WAY, and corrected in place rather than + left. `docs/release-process.md` said the `verify` feature is "**not** + enabled" in release builds and that enabling it "remains a deliberate + follow-up decision". That decision was TAKEN in v0.58 — RQ-58-SHIPVERIFY + (#1000, PR #1002) put `--features verify` on both the native and `cross` + build lines — so the sentence had been false for thirteen releases. + Measured on the shipped v0.70.0 asset: 20 `ordeal` strings and ZERO + occurrences of the degraded-path message, so `synth verify` works in a + released binary. Nobody would have found this from the workflow alone; + it surfaced because this lane had to read what the release actually + builds. + + RESIDUAL, NAMED. arm64 Linux is still glibc-floored — only the x86_64 musl + asset is published. An `aarch64-unknown-linux-musl` asset needs the `cross` + path and its own measurement, and is NOT claimed here. + + status: implemented release: v0.71 tags: - release-engineering - - downstream-consumer + - downstream-blocker - feature-loop links: - type: derives-from target: BR-001 fields: - req-type: constraint + req-type: process priority: should verification-track: differential issue: "#1349" - done-when: "contains:.github/workflows/release.yml:musl" + landed: "#1349" + done-when: "contains:.github/workflows/ci.yml:musl-portable-asset" diff --git a/docs/release-process.md b/docs/release-process.md index 56fb8ce5..cc80db5d 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -30,18 +30,26 @@ an existing tag). synth-cli`) for a host matrix: - `x86_64-unknown-linux-gnu` - `aarch64-unknown-linux-gnu` (via `cross`) + - `x86_64-unknown-linux-musl` — **the portable payload**, statically + linked (RQ-71-MUSL, #1349; see *Linux portability* below) - `x86_64-apple-darwin` - `aarch64-apple-darwin` Each target is stripped, packaged as `synth--.tar.gz` (with `README.md` + `LICENSE`), and uploaded as a workflow artifact. - The build uses only the `riscv` feature (the workspace default). The - `verify` feature is **not** enabled — historically it pulled `z3-sys` - (vendored C++ Z3 build); since #553 it is pure Rust (ordeal engine), so - enabling it in release builds is now feasible but remains a deliberate - follow-up decision. The CLI degrades gracefully: `synth verify` without - the feature fails loudly with "rebuild with `--features verify`". + The build enables the `verify` feature on every target + (`cargo build --release -p synth-cli --features verify`). + + CORRECTED v0.71 (RQ-71-MUSL). This paragraph said the `verify` feature was + **not** enabled and that turning it on "remains a deliberate follow-up + decision". That decision was TAKEN in v0.58 — RQ-58-SHIPVERIFY (#1000, PR + #1002) put `--features verify` on both the native and the `cross` build + lines — and the sentence has been false for thirteen releases. Measured on + the published v0.70.0 x86_64 asset: 20 `ordeal` strings present and ZERO + occurrences of the degraded-path message, so `synth verify` WORKS in a + released binary. Historically the feature pulled `z3-sys` (a vendored C++ Z3 + build), which is why it was once excluded; since #553 it is pure Rust. 2. **`create-release`** — collects all archives, then: - Generates a CycloneDX 1.5 JSON SBOM for the synth toolchain via @@ -65,6 +73,60 @@ an existing tag). completes) — publishes the `@pulseengine/synth` npm wrapper. See the "npm distribution channel" section below. +## Linux portability — which asset a pinned layer should take + +RQ-71-MUSL (#1349). The two `-linux-gnu` assets are built on `ubuntu-latest` and +are dynamically linked against that image's glibc. MEASURED on the published +v0.70.0 x86_64 asset with the issue's own two commands: + +``` +$ file synth +ELF 64-bit LSB pie executable, dynamically linked, +interpreter /lib64/ld-linux-x86-64.so.2 +$ strings -a synth | grep -oE 'GLIBC_[0-9.]+' | sort -V | tail -1 +GLIBC_2.39 +``` + +A **2.39** floor excludes Ubuntu 22.04 (2.35), Debian 12 bookworm (2.36), RHEL 9 +and Amazon Linux 2023 (2.34), Ubuntu 20.04 (2.31), and Alpine / distroless-static +entirely. + +That is not only a convenience problem. These binaries are ingested into the +varve `pulseengine` realm's rolling layer — the pinned toolchain an air-gapped or +enterprise consumer installs — and **a layer's portability is the MAXIMUM floor +across its payloads**, so one 2.39 payload sets the floor for everything pinned +beside it. + +### The rule + +| asset | take it when | +|---|---| +| `x86_64-unknown-linux-musl` | **A varve layer, a container image, or any host whose glibc you do not control.** Statically linked: no loader, no glibc floor at all. This is the payload a pinned layer should reference. | +| `x86_64-unknown-linux-gnu` | A glibc host at 2.39 or newer that you control, and you want the dynamically-linked build. | +| `aarch64-unknown-linux-gnu` | arm64 Linux. **Still carries a glibc floor** — there is no arm64 musl asset yet; see *Residual*. | + +### How it is enforced + +Both the release job and a per-PR CI job (`musl-portable-asset`) run the issue's +two acceptance commands against the built binary: it must be `statically linked`, +must carry no `interpreter`, and must contain **zero** `GLIBC_` version symbols. +The CI job additionally builds the **gnu** target on the same runner and asserts +those checks FAIL there — otherwise they would pass on anything and prove nothing. + +The per-PR job exists because `release.yml` only runs on a tag push: a target +added there is unverified until the tag, and changing the release workflow +between an RC and its tag is the unreviewed late change this process exists to +prevent. (v0.70 shipped the 2.39 floor rather than make one.) + +### Residual, named rather than implied + +- **arm64 Linux is still glibc-floored.** Only `x86_64-unknown-linux-musl` is + published. An `aarch64-unknown-linux-musl` asset needs the `cross` path and its + own measurement, and is not claimed here. +- The musl build is **additive**: the gnu assets keep their names, their + consumers and their attestation path unchanged. Nothing that works today + changes. + ## Provenance and signing model synth uses **two complementary mechanisms**, matching the sibling repos: @@ -144,7 +206,7 @@ Before pushing a `v*` tag: After the workflow finishes: -- [ ] GitHub Release page shows 4 `.tar.gz` archives + `SHA256SUMS.txt` + +- [ ] GitHub Release page shows 5 `.tar.gz` archives + `SHA256SUMS.txt` + `SHA256SUMS.txt.{cosign.bundle,sig,pem}` + `build-env.txt` + `synth-.cdx.json` (toolchain SBOM, Phase 6). - [ ] Spot-check one binary: download, `gh attestation verify`, run From dc52f4265724ec9e6e198905085ef789f6bba18f Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 23 Sep 2026 06:41:30 +0200 Subject: [PATCH 2/5] fix(#1337): render a MODIFIED artifact, not its Python dict, in the release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RQ-70-RIVETNOTES (#1337) shipped `release_notes_from_rivet.py` in v0.70 to derive the CHANGELOG's artifact section instead of re-typing it. Its modified-artifact path had never run. `rivet diff` reports `added` and `removed` as bare id STRINGS but `modified` as `{"id": ..., "changes": [...]}`. The renderer f-stringed the entry directly, so running it for THIS release printed: - *(modified)* **{'changes': ['field changed: issue-scope'], 'id': 'RQ-70-NPA'}** straight into the notes. v0.71 is the first release to modify a PRIOR release's artifact — RQ-71-ISSUESCOPE applies `issue-scope: outlives` to RQ-70-NPA and RQ-70-FALCONCORPUS — so the defect shipped behind a code path nothing reached. Same shape as the four gates v0.70 found that could not fail, one layer out: a renderer arm that could not render. Found by running the derivation against a SIMULATION of the merged tree before cutting, rather than discovering it in the CHANGELOG. The `changes` list is kept rather than dropped — "which field moved on a shipped artifact" is what a reader of a release note actually needs, and it is why a modified entry is richer than an added one: - *(modified)* **RQ-70-FALCONCORPUS** — field changed: issue-scope - *(modified)* **RQ-70-NPA** — field changed: issue-scope Refs #1337 --- scripts/release_notes_from_rivet.py | 34 ++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/scripts/release_notes_from_rivet.py b/scripts/release_notes_from_rivet.py index 31626324..b4ba03cc 100644 --- a/scripts/release_notes_from_rivet.py +++ b/scripts/release_notes_from_rivet.py @@ -60,6 +60,31 @@ CONFIG = "rivet.yaml" VERSION_RE = re.compile(r"^rivet (\d+)\.(\d+)\.(\d+)") +def _entry_id(entry): + """Normalise one `rivet diff` entry to `(id, changes)`. + + RQ-71 (#1337): rivet reports `added` and `removed` as bare id STRINGS but + `modified` as `{"id": ..., "changes": [...]}`. This renderer f-stringed the + entry directly, so a modified artifact printed its whole Python dict into + the CHANGELOG: + + - *(modified)* **{'changes': ['field changed: issue-scope'], 'id': 'RQ-70-NPA'}** + + The path had never run. v0.70 shipped this tool and no release had modified + a PRIOR release's artifact until v0.71 did (RQ-71-ISSUESCOPE applies + `issue-scope: outlives` to two v0.70 artifacts), so the defect shipped + behind a code path nothing reached — the same shape as the four gates v0.70 + found that could not fail. + + The `changes` list is kept rather than dropped: "which field moved on a + shipped artifact" is exactly what a reader of a release note needs, and it + is the reason a modified entry is richer than an added one. + """ + if isinstance(entry, dict): + return str(entry.get("id", entry)), [str(c) for c in (entry.get("changes") or [])] + return str(entry), [] + + SUMMARY_RE = re.compile(r"^(\d+) added, (\d+) removed, (\d+) modified, (\d+) unchanged") DIAG_RE = re.compile( r"^(\d+) new errors?, (\d+) resolved errors?, (\d+) new warnings?, (\d+) resolved warnings?" @@ -175,9 +200,12 @@ def main() -> int: print(f"\n### Artifacts ({data.get('summary', '')})\n") for aid in added: print(f"- **{aid}** — {titles.get(aid, '(title unavailable)')}") - for aid in data.get("modified") or []: - print(f"- *(modified)* **{aid}**") - for aid in data.get("removed") or []: + for entry in data.get("modified") or []: + aid, changes = _entry_id(entry) + why = f" — {', '.join(changes)}" if changes else "" + print(f"- *(modified)* **{aid}**{why}") + for entry in data.get("removed") or []: + aid, _ = _entry_id(entry) print(f"- *(removed)* **{aid}**") print("\n### Trace-graph delta\n") From a4e68462ac982ce2022fc12e8a747bf511c69c54 Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 23 Sep 2026 06:43:36 +0200 Subject: [PATCH 3/5] fix(#1250): name the PR in `landed:` so R10 does not depend on the squash subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MEASURED at the #1356 merge, which reddened main. `gh pr merge --squash` uses the PR TITLE as the subject only when the branch has MORE THAN ONE commit; with exactly one it uses THAT COMMIT'S subject. #1356 had one commit, so main landed feat(issuescope): let an artifact say its ISSUE outlives its delivery ... (#1356) while the four-check ritual's CHECK4 had simulated the PR title "RQ-71-ISSUESCOPE (#1250): ...". The gate passed on a tree that was never created, and main went red on R10 — a delivery-shaped commit attributable to no artifact. Exactly v0.70's squash-simulation lesson, one level deeper: it is not enough to simulate a squash, the simulated SUBJECT has to be the one that will actually land. Two fixes, and only the second is durable: * RQ-71-ISSUESCOPE `landed:` now names PR #1356, and RQ-71-MUSL names #1357. R10 accepts EITHER an artifact id / issue number in the subject OR an artifact whose `landed:` names the PR. Naming the PR makes attribution independent of whatever subject GitHub chooses — the general fix, applied here to both. * the merge ritual now passes `gh pr merge --subject " (#N)"`, so the simulated tree and the real one agree BY CONSTRUCTION rather than by a prediction of GitHub's default. Never simulate one subject and merge another. Refs #1250, #1349 --- artifacts/release-v0.71/RQ-71-ISSUESCOPE.yaml | 2 +- artifacts/release-v0.71/RQ-71-MUSL.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/artifacts/release-v0.71/RQ-71-ISSUESCOPE.yaml b/artifacts/release-v0.71/RQ-71-ISSUESCOPE.yaml index 40c9bef7..3811e840 100644 --- a/artifacts/release-v0.71/RQ-71-ISSUESCOPE.yaml +++ b/artifacts/release-v0.71/RQ-71-ISSUESCOPE.yaml @@ -95,5 +95,5 @@ artifacts: priority: should verification-track: differential issue: "#1250" - landed: "#1250" + landed: "#1250 (PR #1356)" done-when: "contains:.github/workflows/ci.yml:test_issue_closure_check.py" diff --git a/artifacts/release-v0.71/RQ-71-MUSL.yaml b/artifacts/release-v0.71/RQ-71-MUSL.yaml index 8d5f7e3c..b443a0ed 100644 --- a/artifacts/release-v0.71/RQ-71-MUSL.yaml +++ b/artifacts/release-v0.71/RQ-71-MUSL.yaml @@ -78,5 +78,5 @@ artifacts: priority: should verification-track: differential issue: "#1349" - landed: "#1349" + landed: "#1349 (PR #1357)" done-when: "contains:.github/workflows/ci.yml:musl-portable-asset" From 2ccc3d091632b0fd2b6af46a08e5a275aa5bbc9c Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 23 Sep 2026 07:32:18 +0200 Subject: [PATCH 4/5] fix(#1349): the musl gate could not pass, and it was on the wrong runner pool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to RQ-71-MUSL, one from the v0.71 cold review and one from the maintainer. Both are recorded in the artifact rather than quietly applied. 1. THE GATE COULD NOT PASS — the class this release keeps finding, this time built by me. The acceptance step asserted file "$BIN" | grep -q 'statically linked' rustc's musl target NEVER produces that string. Its spec sets `crt-static-default` AND `static-position-independent-executables`, so the link is `-static-pie` and `file` reports "static-pie linked". The gate would have REFUSED A CORRECT ARTIFACT — and `create-release` has `needs: [build-binaries]`, so that is not one missing archive, it is NO RELEASE AT ALL. A gate that cannot pass, in the workflow that publishes the release, in the lane whose entire point is that the asset is checkable. The fix is not a better string. `file`'s wording is a presentation detail of a tool this repo does not control; the PROPERTY is "asks no loader to run it", which is exactly "no PT_INTERP program header". `scripts/elf_static_check.py` reads it from the ELF with nothing beyond the stdlib, and was validated BOTH ways before being wired: the real published v0.70.0 gnu asset -> PT_INTERP=1, GLIBC_ x17, rc=1 a synthesised static-pie shape -> PT_INTERP=0, GLIBC_ x0, rc=0 The potency control was widened with it: the gnu leg now runs the SAME check and must FAIL it, so BOTH assertions have a negative control. Previously the gnu block never asserted a loader was present, so the musl `interpreter` assertion had none. 2. THE RUNNER POOL — raised by the maintainer while this very PR sat queued. I put the job on `ubuntu-latest` by copying the surrounding convention without thinking about capacity. Measured: 56 of ci.yml's 67 jobs target `ubuntu-latest` and only 8 use self-hosted, which is exactly what RQ-61-CICAP identified as THE bottleneck — runs queue for hours beside idle machines because the constraint is the GitHub-hosted quota, not the fleet. Adding another ubuntu-latest job makes that worse for every other lane. Now `[self-hosted, linux, x64, rust-cpu]`. The apt step is conditional and non-fatal: whether rust's bundled musl crt and `rust-lld` make `musl-tools` unnecessary for a pure-Rust workspace is UNVERIFIED here — it cannot be checked on this macOS host (no musl std, no nightly for `--print target-spec-json`) — so it is not asserted either way, and the build step is left to answer it loudly. Refs #1349 --- .github/workflows/ci.yml | 73 +++++++++++++++---------- .github/workflows/release.yml | 30 +++++----- artifacts/release-v0.71/RQ-71-MUSL.yaml | 33 +++++++++++ scripts/elf_static_check.py | 67 +++++++++++++++++++++++ 4 files changed, 161 insertions(+), 42 deletions(-) create mode 100644 scripts/elf_static_check.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbedd6b4..0b54fa23 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5774,7 +5774,12 @@ jobs: # job's whole purpose. musl-portable-asset: name: Portable musl asset builds and is static (#1349) - runs-on: ubuntu-latest + # SELF-HOSTED on purpose. 56 of this file's 67 jobs target `ubuntu-latest`, + # which RQ-61-CICAP measured as THE bottleneck: runs queue for hours beside + # idle self-hosted machines, because the constraint is the GitHub-hosted + # quota and not the fleet. A new job defaulting to ubuntu-latest makes that + # worse for every other lane, so this one goes to the pool that has capacity. + runs-on: [self-hosted, linux, x64, rust-cpu] steps: - uses: actions/checkout@v7 - uses: dtolnay/rust-toolchain@stable @@ -5783,38 +5788,50 @@ jobs: - uses: Swatinem/rust-cache@v2 with: key: musl-portable - - name: Install musl toolchain - run: sudo apt-get update && sudo apt-get install -y musl-tools + # `musl-gcc` is the linker driver rustc invokes for this target IF it is + # not self-containing the link. Whether rust's bundled musl crt + + # `rust-lld` make this unnecessary for a pure-Rust workspace is UNVERIFIED + # here — it cannot be checked on this macOS host (no musl std installed, + # no nightly for `--print target-spec-json`), so it is not asserted either + # way. Installed only when absent and never fatal: if the link genuinely + # needs a driver that is missing, the BUILD step below says so loudly, + # which is the honest place for that answer rather than a guess here. + - name: Install musl toolchain if absent + run: | + if command -v musl-gcc >/dev/null 2>&1; then + echo "musl-gcc already present: $(command -v musl-gcc)" + elif command -v apt-get >/dev/null 2>&1; then + sudo apt-get update && sudo apt-get install -y musl-tools || \ + echo "musl-tools install failed; letting the build be the judge" + else + echo "no apt-get on this runner; letting the build be the judge" + fi # The SAME command release.yml runs for this target. If these diverge the # job stops testing the thing it claims to test. - name: Build synth for x86_64-unknown-linux-musl run: cargo build --release --target x86_64-unknown-linux-musl -p synth-cli --features verify - - name: The issue's own two acceptance commands, on the built artifact - run: | - set -euo pipefail - BIN=target/x86_64-unknown-linux-musl/release/synth - file "$BIN" - file "$BIN" | grep -q 'statically linked' \ - || { echo "FAIL: not statically linked"; exit 1; } - if file "$BIN" | grep -q 'interpreter'; then - echo "FAIL: carries a dynamic loader"; exit 1 - fi - N="$(strings -a "$BIN" | grep -c '^GLIBC_' || true)" - echo "GLIBC_ version symbols: $N (want 0)" - [ "$N" = "0" ] || { echo "FAIL: references glibc versions"; exit 1; } - # POTENCY: the gnu build on this same runner MUST fail those assertions, - # or they prove nothing about staticness — they would pass on anything. - - name: Potency — the gnu target must FAIL the same assertions + # The PROPERTY, read from the ELF — not `file`'s wording. The first + # version of this grepped `file` for "statically linked", which rustc's + # musl target never produces: its spec sets crt-static-default AND + # static-position-independent-executables, so the link is `-static-pie` + # and `file` says "static-pie linked". That gate would have REFUSED A + # CORRECT ARTIFACT (v0.71 cold review). "No loader" is exactly "no + # PT_INTERP", which is read directly and needs no `file` and no `strings`. + - name: The issue's acceptance property, on the built artifact + run: python3 scripts/elf_static_check.py target/x86_64-unknown-linux-musl/release/synth + # POTENCY: the SAME check must FAIL on the gnu build, or it proves nothing + # about this binary — it would pass on anything. This now exercises BOTH + # assertions (PT_INTERP and GLIBC_), which the previous version did not: + # its gnu block never asserted the loader was present, so the musl + # `interpreter` assertion had no negative control (v0.71 cold review). + - name: Potency — the SAME check must FAIL on the gnu target run: | set -euo pipefail cargo build --release --target x86_64-unknown-linux-gnu -p synth-cli --features verify - BIN=target/x86_64-unknown-linux-gnu/release/synth - file "$BIN" - if file "$BIN" | grep -q 'statically linked'; then - echo "FAIL(potency): the gnu build is static, so the musl check is vacuous"; exit 1 + if python3 scripts/elf_static_check.py \ + target/x86_64-unknown-linux-gnu/release/synth; then + echo "FAIL(potency): the gnu build passed the static check, so the" + echo "musl result above is vacuous — it would pass on anything." + exit 1 fi - N="$(strings -a "$BIN" | grep -c '^GLIBC_' || true)" - echo "gnu GLIBC_ version symbols: $N (want > 0)" - [ "$N" -gt 0 ] \ - || { echo "FAIL(potency): no GLIBC_ symbols in the gnu build either"; exit 1; } - echo "gnu floor: $(strings -a "$BIN" | grep -oE '^GLIBC_[0-9.]+' | sort -V | tail -1)" + echo "potency OK: the gnu build is correctly REFUSED by the same check" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 884d2086..084029ed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -124,21 +124,23 @@ jobs: # redden the release rather than ship and be discovered by a consumer — # which is exactly how the 2.39 floor was found in the first place. Run # before `strip`, because stripping is what would hide the symbols. - - name: Verify the musl asset is static with no glibc floor + # RQ-71-MUSL (#1349): the ISSUE'S acceptance property, on the artifact + # this job just built, BEFORE it is packaged — and read from the ELF + # rather than from `file`'s wording. + # + # CORRECTED after the v0.71 cold review, and this one mattered: the first + # version grepped `file` for "statically linked", which rustc's musl + # target NEVER produces (its spec sets crt-static-default AND + # static-position-independent-executables, so the link is `-static-pie` + # and `file` reports "static-pie linked"). The gate would have refused a + # CORRECT artifact — and `create-release` has `needs: [build-binaries]`, + # so that is not one missing archive, it is NO RELEASE AT ALL. A gate + # that cannot pass, in the workflow that publishes the release. + # + # Runs before `strip`, because stripping is what would hide the symbols. + - name: Verify the musl asset has no loader and no glibc floor if: matrix.musl - run: | - set -euo pipefail - BIN="target/${{ matrix.target }}/release/synth" - file "$BIN" - file "$BIN" | grep -q 'statically linked' \ - || { echo "FAIL: musl asset is not statically linked"; exit 1; } - if file "$BIN" | grep -q 'interpreter'; then - echo "FAIL: musl asset carries a dynamic loader"; exit 1 - fi - N="$(strings -a "$BIN" | grep -c '^GLIBC_' || true)" - echo "GLIBC_ version symbols: $N (want 0)" - [ "$N" = "0" ] \ - || { echo "FAIL: musl asset references glibc versions"; exit 1; } + run: python3 scripts/elf_static_check.py "target/${{ matrix.target }}/release/synth" - name: Strip binary if: ${{ !matrix.cross }} diff --git a/artifacts/release-v0.71/RQ-71-MUSL.yaml b/artifacts/release-v0.71/RQ-71-MUSL.yaml index b443a0ed..6081d098 100644 --- a/artifacts/release-v0.71/RQ-71-MUSL.yaml +++ b/artifacts/release-v0.71/RQ-71-MUSL.yaml @@ -60,6 +60,39 @@ artifacts: it surfaced because this lane had to read what the release actually builds. + THE COLD REVIEW FOUND THIS LANE'S GATE COULD NOT PASS, and it is recorded + rather than quietly fixed. The acceptance step asserted + `file "$BIN" | grep -q 'statically linked'`. rustc's musl target NEVER + produces that string: its spec sets `crt-static-default` AND + `static-position-independent-executables`, so the link is `-static-pie` + and `file` reports "static-pie linked". The gate would have REFUSED A + CORRECT ARTIFACT — and `create-release` has `needs: [build-binaries]`, so + that is not one missing archive, it is NO RELEASE AT ALL. A gate that + cannot pass, in the workflow that publishes the release, in the lane whose + whole point is that the asset is checkable. + + The fix is not a better string. `file`'s wording is a presentation detail + of a tool this repo does not control; the property is "asks no loader to + run it", which is exactly "no PT_INTERP program header". + `scripts/elf_static_check.py` reads it from the ELF with nothing beyond + the stdlib, and is validated BOTH ways before shipping: it REFUSES the + real published v0.70.0 gnu asset (PT_INTERP=1, 17 GLIBC_ occurrences, + rc=1) and ACCEPTS a synthesised static-pie shape (rc=0). + + The potency control was widened at the same time: the gnu leg now runs the + SAME check and must fail it, so both assertions have a negative control. + Previously the gnu block never asserted a loader was present, leaving the + musl `interpreter` assertion uncontrolled. + + AND THE JOB WAS PUT ON THE POOL THAT HAS CAPACITY. 56 of ci.yml's 67 jobs + target `ubuntu-latest`, which RQ-61-CICAP measured as THE bottleneck — + runs queue for hours beside idle self-hosted machines, because the + constraint is the GitHub-hosted quota, not the fleet. This job now runs on + `[self-hosted, linux, x64, rust-cpu]`, and its apt step is conditional and + non-fatal: whether rust's bundled musl crt makes `musl-tools` unnecessary + is UNVERIFIED here (it cannot be checked on this macOS host) and is + therefore not asserted — the build step is left to answer it loudly. + RESIDUAL, NAMED. arm64 Linux is still glibc-floored — only the x86_64 musl asset is published. An `aarch64-unknown-linux-musl` asset needs the `cross` path and its own measurement, and is NOT claimed here. diff --git a/scripts/elf_static_check.py b/scripts/elf_static_check.py new file mode 100644 index 00000000..329ced60 --- /dev/null +++ b/scripts/elf_static_check.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Assert an ELF has no dynamic loader and no glibc version symbols. + +RQ-71-MUSL (#1349), corrected after the v0.71 cold review. The first version of +this gate asserted `file "$BIN" | grep -q 'statically linked'`. That string is +NOT what rustc's musl target produces: the target spec sets +`crt-static-default=true` AND `static-position-independent-executables=true`, so +the link is `-static-pie`, and `file` reports "static-pie linked" (or, without +DF_1_PIE, "dynamically linked") — never "statically linked". The gate would have +REFUSED A CORRECT ARTIFACT, and `create-release` has `needs: [build-binaries]`, +so that means no GitHub Release at all rather than one missing archive. + +The fix is not a better string. `file`'s wording is a presentation detail of a +tool we do not control; the PROPERTY we care about is "this binary asks no +loader to run it", which is exactly "no PT_INTERP program header". That is read +straight from the ELF here, with no dependency beyond the stdlib. +""" +import struct +import subprocess +import sys + +PT_INTERP = 3 + + +def program_headers(data): + if data[:4] != b"\x7fELF": + raise SystemExit("FAIL: not an ELF file") + if data[4] != 2: + raise SystemExit("FAIL: not ELF64") + little = data[5] == 1 + end = "<" if little else ">" + phoff, = struct.unpack_from(end + "Q", data, 32) + phentsize, phnum = struct.unpack_from(end + "HH", data, 54) + for i in range(phnum): + off = phoff + i * phentsize + ptype, = struct.unpack_from(end + "I", data, off) + yield ptype, off, end + + +def main(): + path = sys.argv[1] + data = open(path, "rb").read() + try: + out = subprocess.run(["file", path], capture_output=True, text=True).stdout.strip() + print(f" file: {out}") + except FileNotFoundError: + pass + + interp = [p for p, _o, _e in program_headers(data) if p == PT_INTERP] + print(f" PT_INTERP segments: {len(interp)} (want 0 — no dynamic loader)") + + # `strings` is not guaranteed present; scan the bytes directly. + n = data.count(b"GLIBC_") + print(f" GLIBC_ occurrences: {n} (want 0)") + + bad = [] + if interp: + bad.append("carries a PT_INTERP dynamic loader") + if n: + bad.append(f"references glibc versions ({n} occurrences)") + if bad: + raise SystemExit("FAIL: " + "; ".join(bad)) + print(" OK: no loader, no glibc version references") + + +if __name__ == "__main__": + main() From 59956d23610ca1fa8d5fbfe2db11fe78b066765a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 23 Sep 2026 07:43:26 +0200 Subject: [PATCH 5/5] =?UTF-8?q?docs(#1349):=20correct=20the=20`ordeal`=20s?= =?UTF-8?q?tring=20count=20=E2=80=94=2019,=20not=2020?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v0.71 prose-truth cold review checked this number and it is wrong in both readings. Re-derived on the downloaded v0.70.0 x86_64 asset: case-sensitive b"ordeal" -> 19 case-insensitive -> 21 "20" is neither. The figure appeared twice — in RQ-71-MUSL and in the docs/release-process.md paragraph this lane corrected — so it is fixed in both, with the counting method stated so the next reader can reproduce it rather than guess which convention produced the number. The load-bearing half of that sentence is unaffected and was independently re-verified: ZERO occurrences of the degraded-path message, which is what shows `synth verify` genuinely works in a released binary and that the doc claiming `--features verify` is off had been false since v0.58. Refs #1349 --- artifacts/release-v0.71/RQ-71-MUSL.yaml | 4 +++- docs/release-process.md | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/artifacts/release-v0.71/RQ-71-MUSL.yaml b/artifacts/release-v0.71/RQ-71-MUSL.yaml index 6081d098..1e6604cb 100644 --- a/artifacts/release-v0.71/RQ-71-MUSL.yaml +++ b/artifacts/release-v0.71/RQ-71-MUSL.yaml @@ -54,7 +54,9 @@ artifacts: follow-up decision". That decision was TAKEN in v0.58 — RQ-58-SHIPVERIFY (#1000, PR #1002) put `--features verify` on both the native and `cross` build lines — so the sentence had been false for thirteen releases. - Measured on the shipped v0.70.0 asset: 20 `ordeal` strings and ZERO + Measured on the shipped v0.70.0 asset: 19 `ordeal` byte-occurrences + (case-sensitive; 21 case-insensitively — the earlier text said "20", + which is neither, and the v0.71 cold review caught it) and ZERO occurrences of the degraded-path message, so `synth verify` works in a released binary. Nobody would have found this from the workflow alone; it surfaced because this lane had to read what the release actually diff --git a/docs/release-process.md b/docs/release-process.md index cc80db5d..69436ef0 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -46,7 +46,8 @@ an existing tag). decision". That decision was TAKEN in v0.58 — RQ-58-SHIPVERIFY (#1000, PR #1002) put `--features verify` on both the native and the `cross` build lines — and the sentence has been false for thirteen releases. Measured on - the published v0.70.0 x86_64 asset: 20 `ordeal` strings present and ZERO + the published v0.70.0 x86_64 asset: 19 `ordeal` byte-occurrences + (case-sensitive) and ZERO occurrences of the degraded-path message, so `synth verify` WORKS in a released binary. Historically the feature pulled `z3-sys` (a vendored C++ Z3 build), which is why it was once excluded; since #553 it is pure Rust.