From 84b1620e7e9e3d01d33ce803aac557b4d2111ae8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 1 Aug 2026 14:40:47 -0400 Subject: [PATCH] =?UTF-8?q?feat(release):=20binaries=20on=20tag=20?= =?UTF-8?q?=E2=80=94=20Distribution=20Stage=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .github/workflows/ had exactly one workflow and it was test-only: no release job, no artifact upload, no tags-to-binaries path. Installing pmacs meant `git clone` plus knowing the feature-flag matrix. COHERENCE.md §17 grades this "missing — zero release machinery exists"; this moves it to Partial and completes journey step 1. Scope is one stage: binaries when a `v*` tag is pushed, attached to a GitHub Release. Channels, rollback, update-in-place, signing, RHEL 9 and Intel macOS are out of scope and named in the framing's §5. WHAT SHIPS: pmacs and pmacs-gpu, both at 1.1.0, CRDT-enabled, co-located in one archive, with SHA256SUMS. pmacs-protocol stays at 1.0.0 — it is the wire crate and versions on its own schedule. THE VERSION BUMP EXPOSED A REAL DEFECT, and it is the reason this PR touches src/ at all. `InstanceIdentity::for_running_process` is defined in pmacs-protocol and expanded `env!("CARGO_PKG_VERSION")` THERE. `env!` expands in the crate being compiled, so the field documented as "Pmacs version string" carried the PROTOCOL crate's version. That identity reaches Lua as `pmacs.instance.identity()` and goes on the wire in `Hello`, so a 1.1.0 release would have told every attached frontend it was 1.0.0. Nothing could have caught it earlier. Three tests assert `id.pmacs_version == env!("CARGO_PKG_VERSION")` evaluated in the pmacs crate — the correct assertion — but while both crates read 1.0.0 they compared the same number reached by two different paths and COULD NOT FAIL. Deciding to hold pmacs-protocol at 1.0.0 while moving pmacs is what made them discriminating; all three failed on the bump. The version is now a parameter so `env!` expands in the caller's crate. A test can be correct and still prove nothing when the two things it compares are equal for a reason unrelated to the code under test. TWO LAYERS OF BINARY EXCLUSION, and layer 2 is load-bearing — demonstrated, not argued. Cargo auto-discovers src/bin/*.rs, so a release build can produce five binaries and three must never ship (pmacs-audit is a contributor tool; pmacs_fake_lsp and pmacs_fake_mcp are test fixtures). Layer 1 names explicit --bin targets. Layer 2 stages an explicit asset list, and building this branch produced exactly the case it guards: after building ONLY --bin pmacs and -p pmacs-gpu, target/release still held all three forbidden binaries, left by an earlier `cargo test --release`. Swatinem/rust-cache restores that kind of directory in CI. An implementation trusting layer 1 and archiving the directory would have published a fake language server in the first release. The three archive assertions are bite-verified: a smuggled pmacs_fake_lsp, a missing pmacs-gpu, and a cleared executable bit are each caught, with the honest archive passing. THE GLIBC FLOOR IS ASSERTED, NOT TRUSTED. Pinning ubuntu-22.04 sets the floor at 2.35 (Ubuntu 22.04, Debian 12 — NOT RHEL 9 at 2.34, which needs a container or cross-build and is parked). But a pinned runner proves nothing about the artifact, and the failure surfaces as a bare `GLIBC_2.39 not found` on a user's machine with no clue which commit caused it. The build reads versioned-symbol requirements out of the binary and fails above the floor, so switching to ubuntu-latest fails in CI instead of shipping. Bite-verified both directions on a glibc 2.44 host. Both runners are pinned; macos-latest would drift the minimum supported macOS with no commit to point at. Preflight runs before any build: the tag must match the root crate version (stripping a prerelease suffix, so v1.1.0-rc.1 and v1.1.0 both match 1.1.0), and the tagged commit must be an ancestor of main. Both catch mistakes that are cheap now and expensive once a public URL exists. The suite is not re-run — CI already tested the commit — but nothing otherwise enforced that a tag points at a tested one. Verified: fmt, diff-check, clippy with and without crdt, --lib 1896, --lib --features crdt 2081, pmacs-protocol 19, m4 149, required GPU 221, and the full serialized crdt sweep at 3,715 passed / 0 failed / 30 ignored — identical to the pre-change baseline, so the protocol signature change broke nothing. Archive staging, contents, executable bits and both --version outputs were exercised against a real release build locally. No release is cut by this PR. Per the framing's §7 the RC is tagged after merge, from the merge SHA. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 362 ++++++++++++++++++++++++++++ Cargo.lock | 4 +- Cargo.toml | 2 +- README.md | 27 +++ docs/distribution-stage1-framing.md | 112 ++++++++- pmacs-gpu/Cargo.toml | 2 +- pmacs-protocol/src/message.rs | 27 ++- src/attach.rs | 1 + src/daemon.rs | 6 +- src/lua_bindings/mod.rs | 6 +- 10 files changed, 534 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..82ecc30 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,362 @@ +name: Release + +# Distribution Stage 1 — binaries on tag. See +# docs/distribution-stage1-framing.md. +# +# Scope is deliberately one stage: build the two shipped binaries when a +# `v*` tag is pushed and attach them to a GitHub Release. There are no +# channels, no rollback, no update-in-place, no signing. Those are named +# as out of scope in the framing's §5 rather than forgotten. +# +# Nothing here runs on a branch push or a pull request. A tag is the +# only trigger, because a release built from anything else would publish +# artifacts for a commit nobody reviewed as a release. +on: + push: + tags: + - 'v*' + +# A release must never race itself. Two tags pushed close together would +# otherwise both mutate the same release page. +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: write + +jobs: + # --------------------------------------------------------------- + # Preflight runs BEFORE any build, because both of its checks catch + # mistakes that are cheap now and expensive once artifacts exist: a + # published release is a public URL, and unpublishing one is not the + # same as never having published it. + # --------------------------------------------------------------- + preflight: + name: Preflight (tag/version, ancestry) + runs-on: ubuntu-22.04 + timeout-minutes: 10 + outputs: + version: ${{ steps.check.outputs.version }} + prerelease: ${{ steps.check.outputs.prerelease }} + steps: + # Full history: the ancestry check below cannot run on a shallow + # clone, and the default checkout depth is 1. + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - id: check + name: Tag must match the root crate version, and be on main + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + + # Strip the leading v, then strip any prerelease suffix: + # v1.1.0-rc.1 and v1.1.0 must both match a crate version of + # 1.1.0. Cargo has no place to record "rc.1", so requiring the + # manifest to carry it would make prereleases impossible. + version="${tag#v}" + base="${version%%-*}" + + # Read the ROOT package version specifically. A grep for + # `^version` across the workspace would match whichever + # manifest sorted first, and pmacs-protocol deliberately holds + # a different number. + manifest=$(cargo metadata --no-deps --format-version 1 \ + | python3 -c 'import json,sys; print(next(p["version"] for p in json.load(sys.stdin)["packages"] if p["name"]=="pmacs"))') + + echo "tag=$tag base=$base manifest=$manifest" + if [ "$base" != "$manifest" ]; then + echo "::error::tag $tag implies version $base but the pmacs crate is $manifest." \ + "Bump Cargo.toml or fix the tag; publishing this would ship a binary whose" \ + "--version disagrees with its release." + exit 1 + fi + + # A tag on a branch is the realistic mistake. Re-running the + # test suite here would be duplicated cost -- CI already tested + # this commit -- but nothing otherwise enforces that the tagged + # commit is one CI ever saw on main. + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::tagged commit $GITHUB_SHA is not an ancestor of origin/main." \ + "Releases are cut from main." + exit 1 + fi + + # Anything with a suffix (-rc.1, -beta) is a prerelease. The + # framing requires the RC to be marked so it cannot be mistaken + # for the real thing on the releases page. + if [ "$version" = "$base" ]; then + echo "prerelease=false" >> "$GITHUB_OUTPUT" + else + echo "prerelease=true" >> "$GITHUB_OUTPUT" + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + + # --------------------------------------------------------------- + # Both runners are PINNED, never `-latest`. Two different reasons, + # both about a silent choice: + # + # * ubuntu-22.04 sets the glibc floor at 2.35, which covers Ubuntu + # 22.04 and Debian 12. `ubuntu-latest` (24.04, glibc 2.39) would + # silently produce binaries that fail to load on both with a bare + # `GLIBC_2.39 not found`. Note the floor does NOT reach RHEL 9 + # (glibc 2.34) -- that needs a container or cross-build and is + # parked in the framing's §5. + # + # * macos-15 is arm64. `macos-latest` drifts, so a future runner + # change could move the minimum supported macOS with no commit in + # this repository to point at. + # --------------------------------------------------------------- + build: + name: Build ${{ matrix.target-label }} + needs: preflight + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-22.04 + target-label: linux-x86_64 + - os: macos-15 + target-label: macos-arm64 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + # CRDT is not optional. `run_gpu` refuses outright without it + # ("--gpu requires pmacs built with --features crdt"), and + # InstanceCapabilities::default advertises multi_frontend / + # crdt_replica / semantic_render only under the feature. A + # non-CRDT release would ship an editor that cannot use the GPU + # frontend shipped beside it. + # + # EXPLICIT --bin TARGETS, layer 1 of 2. Cargo auto-discovers + # src/bin/*.rs, so a plain `--workspace` release build also + # produces pmacs-audit, pmacs_fake_lsp and pmacs_fake_mcp -- the + # last two being test fixtures. Naming targets keeps them from + # being built at all. + - name: Build the shipped binaries only + run: | + set -euo pipefail + cargo build --release --bin pmacs --features crdt + cargo build --release -p pmacs-gpu + + # EXPLICIT STAGED ASSET LIST, layer 2 of 2. Layer 1 alone is not + # enough: a cached target/release can still hold binaries from an + # earlier build, and archiving that directory would publish them. + # Copying named files into a clean staging dir makes the archive's + # contents a decision rather than a directory's residue. + # + # The two binaries are staged into the SAME directory on purpose. + # `pmacs --gpu` prefers a co-located pmacs-gpu and only then falls + # back to a PATH lookup, so co-location is what makes an unpacked + # release self-contained for someone who unpacks it off PATH. + - name: Stage the archive + id: stage + run: | + set -euo pipefail + name="pmacs-${{ needs.preflight.outputs.version }}-${{ matrix.target-label }}" + mkdir -p "staging/$name" + cp target/release/pmacs "staging/$name/" + cp target/release/pmacs-gpu "staging/$name/" + cp README.md LICENSE* "staging/$name/" 2>/dev/null || true + chmod +x "staging/$name/pmacs" "staging/$name/pmacs-gpu" + tar -C staging -czf "$name.tar.gz" "$name" + echo "archive=$name.tar.gz" >> "$GITHUB_OUTPUT" + echo "name=$name" >> "$GITHUB_OUTPUT" + + # Assert the archive rather than the build command. The build + # could change, a target could be added, a cache could leak -- the + # only thing that decides what users receive is what is inside + # this file. + - name: Assert archive contents + run: | + set -euo pipefail + archive="${{ steps.stage.outputs.archive }}" + name="${{ steps.stage.outputs.name }}" + + members=$(tar -tzf "$archive" | sed "s#^$name/##" | grep -v '^$' | sort) + echo "members:"; echo "$members" | sed 's/^/ /' + + for forbidden in pmacs-audit pmacs_fake_lsp pmacs_fake_mcp; do + if echo "$members" | grep -qx "$forbidden"; then + echo "::error::$forbidden is present in $archive and must never ship" + exit 1 + fi + done + + for required in pmacs pmacs-gpu; do + echo "$members" | grep -qx "$required" \ + || { echo "::error::$required missing from $archive"; exit 1; } + done + + # Executable bits, read back out of the archive itself. + for required in pmacs pmacs-gpu; do + mode=$(tar -tvzf "$archive" | awk -v f="$name/$required" '$NF==f {print $1}') + case "$mode" in + *x*) : ;; + *) echo "::error::$required is not executable in the archive (mode $mode)"; exit 1 ;; + esac + done + + # Run the staged binaries. `--version` is the cheapest end-to-end + # proof that what was built is what will ship, and it is the check + # that would have caught a tag/manifest mismatch reaching this far. + - name: Assert the staged binaries report the tagged version + run: | + set -euo pipefail + expected="${{ needs.preflight.outputs.version }}" + base="${expected%%-*}" + name="${{ steps.stage.outputs.name }}" + + got_pmacs=$("staging/$name/pmacs" --version) + got_gpu=$("staging/$name/pmacs-gpu" --version) + echo "pmacs: $got_pmacs" + echo "pmacs-gpu: $got_gpu" + + echo "$got_pmacs" | grep -qx "pmacs $base" \ + || { echo "::error::pmacs --version reported '$got_pmacs', expected 'pmacs $base'"; exit 1; } + echo "$got_gpu" | grep -q "^pmacs-gpu $base " \ + || { echo "::error::pmacs-gpu --version reported '$got_gpu', expected 'pmacs-gpu $base ...'"; exit 1; } + + # The glibc floor, MACHINE-CHECKED rather than trusted. + # + # Pinning ubuntu-22.04 is what sets the floor, but nothing about a + # pinned runner *proves* the resulting binary honours it, and the + # failure is invisible at build time -- it surfaces as a bare + # `GLIBC_2.xx not found` on a user's machine, with no clue which + # commit caused it. Reading the required symbol versions out of + # the binary turns a runner choice into an assertion, so switching + # this job to `ubuntu-latest` fails HERE instead of shipping. + # + # Linux only: Mach-O has no equivalent versioned-symbol scheme. + - name: Assert the glibc floor + if: runner.os == 'Linux' + run: | + set -euo pipefail + floor="2.35" # Ubuntu 22.04 / Debian 12; see the framing §1.6 + name="${{ steps.stage.outputs.name }}" + fail=0 + for bin in pmacs pmacs-gpu; do + max=$(objdump -T "staging/$name/$bin" \ + | grep -oE 'GLIBC_[0-9]+\.[0-9]+' \ + | sed 's/GLIBC_//' | sort -uV | tail -1) + echo "$bin requires at most glibc $max (floor $floor)" + highest=$(printf '%s\n%s\n' "$floor" "$max" | sort -V | tail -1) + if [ "$highest" != "$floor" ]; then + echo "::error::$bin requires glibc $max, above the declared floor $floor." \ + "It will not load on Ubuntu 22.04 or Debian 12. Check the runner image." + fail=1 + fi + done + [ "$fail" -eq 0 ] + + - name: Checksum + run: | + set -euo pipefail + archive="${{ steps.stage.outputs.archive }}" + if command -v sha256sum >/dev/null; then + sha256sum "$archive" > "$archive.sha256" + else + shasum -a 256 "$archive" > "$archive.sha256" + fi + cat "$archive.sha256" + + - uses: actions/upload-artifact@v4 + with: + name: ${{ steps.stage.outputs.name }} + path: | + ${{ steps.stage.outputs.archive }} + ${{ steps.stage.outputs.archive }}.sha256 + if-no-files-found: error + + publish: + name: Publish release + needs: [preflight, build] + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + + # One SHA256SUMS covering every published artifact, alongside the + # per-archive files. Without it a download cannot be verified by + # anyone; with it, verification is one command. + - name: Collect checksums + run: | + set -euo pipefail + cd dist + ls -la + cat ./*.sha256 | sed 's#\./##' | sort -k2 > SHA256SUMS + echo "--- SHA256SUMS ---"; cat SHA256SUMS + sha256sum -c SHA256SUMS + + - name: Release notes + run: | + set -euo pipefail + cat > notes.md <<'NOTES' + ## Install + + Download the archive for your platform, unpack it, and put both + binaries somewhere on your `PATH` — **keep them together**: + `pmacs --gpu` looks for `pmacs-gpu` beside itself first, then + falls back to `PATH`. + + Verify a download against `SHA256SUMS`: + + ```sh + sha256sum -c SHA256SUMS --ignore-missing + ``` + + ## Platform support + + - **Linux x86_64** — built on Ubuntu 22.04, so the floor is + **glibc ≥ 2.35**. Covers Ubuntu 22.04+ and Debian 12+. + **RHEL 9 (glibc 2.34) is below the floor and not supported + yet.** + - **macOS arm64 (Apple Silicon)** — Intel macOS is not built + yet. The binaries are **unsigned and not notarized**, so + Gatekeeper will quarantine them; you will need to allow them + explicitly. + + ## Runtime dependencies + + Not checked at startup — the editor assumes them: + `/bin/sh`, `stty` and coreutils (PTY raw-mode trampoline, used + by the REPL and terminal), plus `git` and `tar` for package + installation. The Lua VM is statically vendored, so there is no + external Lua dependency. + + `pmacs-gpu` additionally needs a working Vulkan (Linux) or + Metal (macOS) adapter. There is no software-rasterizer + fallback in a shipped binary. + + ## Not in this release + + Channels, in-place update, rollback, signing, reproducible + builds, package-manager distribution, and Windows. See + `docs/distribution-stage1-framing.md` §5. + NOTES + echo "notes written" + + - name: Publish + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + flags=(--title "pmacs ${{ needs.preflight.outputs.version }}" --notes-file notes.md) + if [ "${{ needs.preflight.outputs.prerelease }}" = "true" ]; then + flags+=(--prerelease) + fi + gh release create "${GITHUB_REF_NAME}" "${flags[@]}" \ + dist/*.tar.gz dist/SHA256SUMS diff --git a/Cargo.lock b/Cargo.lock index 610ddd0..f3a5447 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2568,7 +2568,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "pmacs" -version = "1.0.0" +version = "1.1.0" dependencies = [ "arborium-lean", "codebook-tree-sitter-latex", @@ -2621,7 +2621,7 @@ dependencies = [ [[package]] name = "pmacs-gpu" -version = "0.0.1" +version = "1.1.0" dependencies = [ "arboard", "env_logger", diff --git a/Cargo.toml b/Cargo.toml index e8319f9..9406924 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,7 +21,7 @@ unicode-width = "0.2" [package] name = "pmacs" default-run = "pmacs" -version = "1.0.0" +version = "1.1.0" edition = "2024" rust-version = "1.95" description = "Parallel Emacs --- a Rust-cored, Lua-scripted editor in the Emacs tradition" diff --git a/README.md b/README.md index 6225678..f1554c0 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,33 @@ loaded after the builtin runtime so plain assignments override defaults --- keybindings, `pmacs.lsp.config`, theme overrides, and package installs all live there. +## Install + +Download an archive from the +[releases page](https://github.com/levineuwirth/pmacs/releases), unpack +it, and put both binaries somewhere on your `PATH`. + +**Keep `pmacs` and `pmacs-gpu` together.** `pmacs --gpu` looks for +`pmacs-gpu` beside itself first and only then falls back to a `PATH` +lookup, so an unpacked release is self-contained as long as the two +stay in the same directory. + +Verify a download: + +```sh +sha256sum -c SHA256SUMS --ignore-missing +``` + +| platform | built on | notes | +|---|---|---| +| Linux x86_64 | Ubuntu 22.04 | requires **glibc ≥ 2.35** — Ubuntu 22.04+, Debian 12+. **RHEL 9 (glibc 2.34) is not supported yet.** | +| macOS arm64 | macOS 15 | Apple Silicon only; Intel is not built yet. Binaries are **unsigned and not notarized**, so Gatekeeper will quarantine them until you allow them explicitly. | + +Releases carry binaries only — there is no in-place update, rollback, or +package-manager distribution yet. Build from source for any platform not +listed, and see "Runtime dependencies" below for what the editor assumes +is present. + ## Build Builds on the toolchain pinned in `rust-toolchain.toml` (Rust diff --git a/docs/distribution-stage1-framing.md b/docs/distribution-stage1-framing.md index eb493a1..833b43c 100644 --- a/docs/distribution-stage1-framing.md +++ b/docs/distribution-stage1-framing.md @@ -1,7 +1,25 @@ # Framing — Distribution Stage 1: binaries on tag -**Revision 2.** Status: **approved with amendments** (revision 1 → -2 records them). Scouted against `githubsucks/main` @ `c5f7501` (#209). +**Revision 3.** Status: **implemented** on branch `distribution-stage1`, +based on `githubsucks/main` @ `4984169` (#210). Approved at revision 2. + +**Revision 2 → 3** records two implementation findings, not a new design +round: + +- **Layer 2 of the binary exclusion is load-bearing, and this is now + demonstrated rather than argued** (§1.2b). The argument for it was + hypothetical; building the branch produced the exact case it guards + against, on the first try. +- **The glibc floor is machine-checked** (§1.6a), which is stronger than + acceptance 7's original container test and runs on every release + instead of once at RC time. +- **The version bump exposed a real product defect** (§1.3a): the daemon + reported the *protocol* crate's version to every attached frontend + under a field named `pmacs_version`. It was invisible while the two + crates happened to share a number, and Q#D1's decision to diverge them + is what surfaced it. Fixed here, because shipping a release whose + daemon misreports its own version is precisely what this stage + exists to prevent. `.github/workflows/` contains exactly one workflow and it is test-only. **There is no release job, no artifact upload, no tags-to-binaries path.** @@ -94,6 +112,60 @@ layer 1 relies on a list nobody re-checks when a new `src/bin/*.rs` appears. Acceptance 2 asserts the **complete member list**, the **executable bits**, and the **absence of all three** excluded binaries. +### 1.2b Layer 2 is load-bearing — demonstrated, not argued + +Revision 2 justified the second layer with a hypothetical: "a cached +`target/release` can still hold binaries from an earlier build." +**Implementing the branch produced that case immediately.** After +running only + +```sh +cargo build --release --bin pmacs --features crdt +cargo build --release -p pmacs-gpu +``` + +on a tree where earlier work had run `cargo test --release` for the M10 +perf gates, `target/release` contained: + +``` +pmacs pmacs-audit pmacs_fake_lsp pmacs_fake_mcp pmacs-gpu +``` + +**All three forbidden binaries were present**, left by the earlier test +build, despite this build naming only two targets. `Swatinem/rust-cache` +restores exactly this kind of directory in CI, so the risk is not +theoretical there either. + +An implementation that took layer 1 as sufficient and archived +`target/release` would have published a fake language server in the +first release. The three archive assertions are bite-verified: a +smuggled `pmacs_fake_lsp`, a missing `pmacs-gpu`, and a cleared +executable bit are each caught, with the honest archive passing. + +### 1.6a The glibc floor is asserted, not trusted + +Acceptance 7 originally proposed verifying the floor by running the +binary in containers. **The shipped check is stronger and cheaper**: +read the versioned-symbol requirements straight out of the binary and +fail the build when any exceeds the floor. + +```sh +objdump -T | grep -oE 'GLIBC_[0-9]+\.[0-9]+' | sort -uV | tail -1 +``` + +Why this beats the container test: it runs on **every** release rather +than once at RC time, it needs no network or images, and it fails at the +point of causation. Pinning `ubuntu-22.04` sets the floor but proves +nothing about the artifact — switching the job to `ubuntu-latest` would +otherwise ship binaries that fail to load with a bare `GLIBC_2.39 not +found` on a user's machine, with no clue which commit caused it. With +the assertion, that change fails in CI instead. + +Bite-verified in both directions on a glibc 2.44 host, which stands in +for a mis-pinned runner: against a 2.35 floor both binaries are caught; +against a 2.44 floor both pass. Linux only — Mach-O has no equivalent +versioned-symbol scheme. + ### 1.2a Why `pmacs` and `pmacs-gpu` must be co-located — corrected Revision 1 said separating them makes `--gpu` "silently fail." **That is @@ -131,6 +203,36 @@ containing a `pmacs` reporting `1.0.0` and a `pmacs-gpu` reporting `0.0.1`. **The workflow must refuse rather than publish** (acceptance 4), and acceptance asserts the *binaries'* output, not the manifests. +### 1.3a The bump exposed a defect: the daemon reported the wrong crate's version + +**`InstanceIdentity::for_running_process` is defined in +`pmacs-protocol` and expanded `env!("CARGO_PKG_VERSION")` there.** +`env!` expands in the crate being *compiled*, so the field documented as +"Pmacs version string" carried the **protocol crate's** version. + +This is not cosmetic. That identity reaches Lua as +`pmacs.instance.identity()` and goes on the wire in `Hello`, so every +attached frontend was told the daemon's version — and after the bump it +would have been told `1.0.0` by a `1.1.0` release. + +**Nothing could have detected it before this stage.** Three tests assert +`id.pmacs_version == env!("CARGO_PKG_VERSION")` evaluated in the `pmacs` +crate, which is the correct assertion — but while both crates read +`1.0.0` they compared the same number reached by two different paths and +**could not fail**. Q#D1's decision to hold `pmacs-protocol` at 1.0.0 +while moving `pmacs` is what made them discriminating, and all three +failed immediately on the bump. + +The fix makes the version a **parameter**, so the `env!` expands in the +caller's crate; all three call sites are in `pmacs` and pass their own. +This is a breaking signature change to a `pub` function in +`pmacs-protocol`, which stays at 1.0.0 per Q#D1 — acceptable because the +crate is a path dependency with no external consumers, and recorded here +rather than silently absorbed. + +*A test can be correct and still prove nothing, when the two things it +compares are equal for a reason unrelated to the code under test.* + ### 1.4 The existing `v1.0.0` tag is stale and is not re-used `v1.0.0` is pushed and points at `d3fa632` — the old release mirror's @@ -257,8 +359,10 @@ change the minimum supported macOS without a commit to point at. 5. The tagged commit is an ancestor of `main`. 6. Each artifact is a **CRDT build**, verified by running the shipped binary rather than trusting the flag (§1.5). -7. The Linux binary **runs on Ubuntu 22.04 and Debian 12** — verified in - containers — and its glibc floor is stated in the release notes. +7. The Linux binary honours the **glibc ≥ 2.35** floor, asserted from + the binary's own versioned symbols on every release (§1.6a) rather + than by a one-off container run, and the floor is stated in the + release notes and README. 8. `SHA256SUMS` covers every published artifact and verifies against the downloads. 9. Release notes carry the runtime dependencies (§1.7), the glibc floor, diff --git a/pmacs-gpu/Cargo.toml b/pmacs-gpu/Cargo.toml index 2715d57..53f6618 100644 --- a/pmacs-gpu/Cargo.toml +++ b/pmacs-gpu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pmacs-gpu" -version = "0.0.1" +version = "1.1.0" edition = "2024" rust-version = "1.95" description = "GPU/GUI frontend for pmacs — attach, editing, syntax/diagnostics/minimap, mouse + context menu, clipboard, search, minibuffer. See docs/pmacs-gpu-design.md." diff --git a/pmacs-protocol/src/message.rs b/pmacs-protocol/src/message.rs index 92ccb7a..5fd7c87 100644 --- a/pmacs-protocol/src/message.rs +++ b/pmacs-protocol/src/message.rs @@ -1885,13 +1885,30 @@ impl InstanceIdentity { /// call site, so calling twice on different days surfaces different /// uptimes from the same anchor. /// - /// The version comes from `CARGO_PKG_VERSION` and the build hash - /// from the optional `PMACS_GIT_HASH` environment variable populated - /// by the build script. + /// `pmacs_version` is supplied BY THE CALLER, and must be + /// `env!("CARGO_PKG_VERSION")` evaluated in the `pmacs` crate. + /// + /// It is a parameter rather than an `env!` here because `env!` + /// expands in the crate being COMPILED: reading it inside + /// `pmacs-protocol` yields the *protocol* crate's version, which + /// this field is not. The two crates held the same number until the + /// 1.1.0 release bump moved `pmacs` and left `pmacs-protocol` at + /// 1.0.0 — at which point the daemon began reporting the protocol + /// crate's version to every attached frontend, under a field named + /// `pmacs_version`. Nothing detected it earlier because the values + /// had always agreed by coincidence. + /// + /// The build hash still comes from the optional `PMACS_GIT_HASH` + /// environment variable populated by the build script; that one is + /// genuinely crate-independent. #[must_use] - pub fn for_running_process(instance_name: Option, started: std::time::Instant) -> Self { + pub fn for_running_process( + pmacs_version: &str, + instance_name: Option, + started: std::time::Instant, + ) -> Self { Self { - pmacs_version: env!("CARGO_PKG_VERSION").into(), + pmacs_version: pmacs_version.into(), build_hash: option_env!("PMACS_GIT_HASH").map(String::from), instance_name, uptime_secs: started.elapsed().as_secs(), diff --git a/src/attach.rs b/src/attach.rs index e7f58e2..c8950fe 100644 --- a/src/attach.rs +++ b/src/attach.rs @@ -2393,6 +2393,7 @@ mod tests { protocol_version: PROTOCOL_VERSION + 999, assigned_frontend_id: FrontendId::LOCAL, instance_identity: InstanceIdentity::for_running_process( + env!("CARGO_PKG_VERSION"), None, std::time::Instant::now(), ), diff --git a/src/daemon.rs b/src/daemon.rs index 9c4dd76..62cacc8 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -422,7 +422,11 @@ impl DaemonState { } fn build_identity(&self) -> InstanceIdentity { - InstanceIdentity::for_running_process(self.instance_name.clone(), self.started) + InstanceIdentity::for_running_process( + env!("CARGO_PKG_VERSION"), + self.instance_name.clone(), + self.started, + ) } /// `--socket NAME` value the daemon was launched with, or `None` diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index a8a130b..2d4f359 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -395,7 +395,11 @@ impl LocalInstanceInfo { #[must_use] pub fn build_identity(&self) -> InstanceIdentity { let data = self.0.borrow(); - InstanceIdentity::for_running_process(data.name.clone(), data.started) + InstanceIdentity::for_running_process( + env!("CARGO_PKG_VERSION"), + data.name.clone(), + data.started, + ) } }