feat(release): binaries on tag — Distribution Stage 1

.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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-08-01 14:40:47 -04:00
parent 2ed2596046
commit 84b1620e7e
No known key found for this signature in database
10 changed files with 534 additions and 15 deletions

362
.github/workflows/release.yml vendored Normal file
View File

@ -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

4
Cargo.lock generated
View File

@ -2568,7 +2568,7 @@ checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]] [[package]]
name = "pmacs" name = "pmacs"
version = "1.0.0" version = "1.1.0"
dependencies = [ dependencies = [
"arborium-lean", "arborium-lean",
"codebook-tree-sitter-latex", "codebook-tree-sitter-latex",
@ -2621,7 +2621,7 @@ dependencies = [
[[package]] [[package]]
name = "pmacs-gpu" name = "pmacs-gpu"
version = "0.0.1" version = "1.1.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"env_logger", "env_logger",

View File

@ -21,7 +21,7 @@ unicode-width = "0.2"
[package] [package]
name = "pmacs" name = "pmacs"
default-run = "pmacs" default-run = "pmacs"
version = "1.0.0" version = "1.1.0"
edition = "2024" edition = "2024"
rust-version = "1.95" rust-version = "1.95"
description = "Parallel Emacs --- a Rust-cored, Lua-scripted editor in the Emacs tradition" description = "Parallel Emacs --- a Rust-cored, Lua-scripted editor in the Emacs tradition"

View File

@ -141,6 +141,33 @@ loaded after the builtin runtime so plain assignments override
defaults --- keybindings, `pmacs.lsp.config`, theme overrides, and defaults --- keybindings, `pmacs.lsp.config`, theme overrides, and
package installs all live there. 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 ## Build
Builds on the toolchain pinned in `rust-toolchain.toml` (Rust Builds on the toolchain pinned in `rust-toolchain.toml` (Rust

View File

@ -1,7 +1,25 @@
# Framing — Distribution Stage 1: binaries on tag # Framing — Distribution Stage 1: binaries on tag
**Revision 2.** Status: **approved with amendments** (revision 1 → **Revision 3.** Status: **implemented** on branch `distribution-stage1`,
2 records them). Scouted against `githubsucks/main` @ `c5f7501` (#209). 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. `.github/workflows/` contains exactly one workflow and it is test-only.
**There is no release job, no artifact upload, no tags-to-binaries path.** **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 appears. Acceptance 2 asserts the **complete member list**, the
**executable bits**, and the **absence of all three** excluded binaries. **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 <binary> | 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 ### 1.2a Why `pmacs` and `pmacs-gpu` must be co-located — corrected
Revision 1 said separating them makes `--gpu` "silently fail." **That is 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 `0.0.1`. **The workflow must refuse rather than publish** (acceptance
4), and acceptance asserts the *binaries'* output, not the manifests. 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 ### 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 `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`. 5. The tagged commit is an ancestor of `main`.
6. Each artifact is a **CRDT build**, verified by running the shipped 6. Each artifact is a **CRDT build**, verified by running the shipped
binary rather than trusting the flag (§1.5). binary rather than trusting the flag (§1.5).
7. The Linux binary **runs on Ubuntu 22.04 and Debian 12** — verified in 7. The Linux binary honours the **glibc ≥ 2.35** floor, asserted from
containers — and its glibc floor is stated in the release notes. 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 8. `SHA256SUMS` covers every published artifact and verifies against the
downloads. downloads.
9. Release notes carry the runtime dependencies (§1.7), the glibc floor, 9. Release notes carry the runtime dependencies (§1.7), the glibc floor,

View File

@ -1,6 +1,6 @@
[package] [package]
name = "pmacs-gpu" name = "pmacs-gpu"
version = "0.0.1" version = "1.1.0"
edition = "2024" edition = "2024"
rust-version = "1.95" 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." description = "GPU/GUI frontend for pmacs — attach, editing, syntax/diagnostics/minimap, mouse + context menu, clipboard, search, minibuffer. See docs/pmacs-gpu-design.md."

View File

@ -1885,13 +1885,30 @@ impl InstanceIdentity {
/// call site, so calling twice on different days surfaces different /// call site, so calling twice on different days surfaces different
/// uptimes from the same anchor. /// uptimes from the same anchor.
/// ///
/// The version comes from `CARGO_PKG_VERSION` and the build hash /// `pmacs_version` is supplied BY THE CALLER, and must be
/// from the optional `PMACS_GIT_HASH` environment variable populated /// `env!("CARGO_PKG_VERSION")` evaluated in the `pmacs` crate.
/// by the build script. ///
/// 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] #[must_use]
pub fn for_running_process(instance_name: Option<String>, started: std::time::Instant) -> Self { pub fn for_running_process(
pmacs_version: &str,
instance_name: Option<String>,
started: std::time::Instant,
) -> Self {
Self { Self {
pmacs_version: env!("CARGO_PKG_VERSION").into(), pmacs_version: pmacs_version.into(),
build_hash: option_env!("PMACS_GIT_HASH").map(String::from), build_hash: option_env!("PMACS_GIT_HASH").map(String::from),
instance_name, instance_name,
uptime_secs: started.elapsed().as_secs(), uptime_secs: started.elapsed().as_secs(),

View File

@ -2393,6 +2393,7 @@ mod tests {
protocol_version: PROTOCOL_VERSION + 999, protocol_version: PROTOCOL_VERSION + 999,
assigned_frontend_id: FrontendId::LOCAL, assigned_frontend_id: FrontendId::LOCAL,
instance_identity: InstanceIdentity::for_running_process( instance_identity: InstanceIdentity::for_running_process(
env!("CARGO_PKG_VERSION"),
None, None,
std::time::Instant::now(), std::time::Instant::now(),
), ),

View File

@ -422,7 +422,11 @@ impl DaemonState {
} }
fn build_identity(&self) -> InstanceIdentity { 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` /// `--socket NAME` value the daemon was launched with, or `None`

View File

@ -395,7 +395,11 @@ impl LocalInstanceInfo {
#[must_use] #[must_use]
pub fn build_identity(&self) -> InstanceIdentity { pub fn build_identity(&self) -> InstanceIdentity {
let data = self.0.borrow(); 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,
)
} }
} }