From 2ed259604626f4788699a749c487a71b1c45fbc7 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 1 Aug 2026 14:14:07 -0400 Subject: [PATCH 1/3] =?UTF-8?q?docs:=20frame=20Distribution=20Stage=201=20?= =?UTF-8?q?=E2=80=94=20binaries=20on=20tag?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .github/workflows/ contains exactly one workflow and it is test-only. There is no release job, no artifact upload, no tags-to-binaries path. Installing pmacs means `git clone` plus knowing the feature-flag matrix. COHERENCE.md §17 grades this "missing — zero release machinery exists", and §20 puts it at Priority 8 with the note that every other priority's value is invisible until it exists. Scope is deliberately ONE STAGE: binaries when a tag is pushed, attached to a GitHub Release. Channels, rollback, update-in-place, signing and first-launch tool detection are out of scope and named in §5. A baseline, not the arc. Revision 2, approved with amendments. Two of them corrected revision 1: THE GLIBC ARITHMETIC. Revision 1 implied that moving off ubuntu-latest buys reach "several distro generations" including RHEL 9. It does not. RHEL 9 ships glibc 2.34, which is BELOW the 2.35 floor a pinned ubuntu-22.04 build sets, so RHEL 9 is excluded by that choice rather than covered by it. The floor is now stated explicitly with its table, so a RHEL 9 user reads "not supported yet" instead of discovering a loader error; supporting it needs a lower-glibc container or cross-build and is parked. WHY CO-LOCATION IS REQUIRED. Revision 1 said separating `pmacs` from `pmacs-gpu` makes `--gpu` "silently fail". Verified against src/main.rs:304: `gpu_binary` prefers a co-located binary when that path is_file(), falls back to a bare-name PATH lookup, and on failure names both — "sibling … is absent and PATH lookup for pmacs-gpu failed". So the requirement is that a release archive be SELF-CONTAINED for a user who unpacks it somewhere off PATH, not that failure is quiet. The load-bearing finding: a release build can produce FIVE binaries and three must never ship. Cargo auto-discovers src/bin/*.rs, so alongside pmacs and pmacs-audit it also builds pmacs_fake_lsp and pmacs_fake_mcp — test fixtures. Exclusion is therefore two-layered, because neither layer suffices alone: explicit --bin targets so the unwanted binaries are not produced, AND an explicit staged asset list so the archive's contents are a decision rather than a directory's residue. Layer 1 alone still archives whatever a cached target/release holds from an earlier step; layer 2 alone relies on a list nobody re-checks when a new src/bin/*.rs appears. Two facts checked rather than assumed while framing: `pmacs-gpu --version` already exists (pmacs-gpu/src/main.rs:667) and prints "pmacs-gpu (protocol v21)", so asserting both binaries report 1.1.0 is directly implementable after the bump. And `run_gpu` refuses a non-CRDT build outright — "pmacs: --gpu requires pmacs built with --features crdt" — which is a direct citation for shipping CRDT rather than the inference from capability defaults revision 1 used. Framing only. No workflow, no version bump, no release. Co-Authored-By: Claude Opus 5 (1M context) --- docs/distribution-stage1-framing.md | 321 ++++++++++++++++++++++++++++ 1 file changed, 321 insertions(+) create mode 100644 docs/distribution-stage1-framing.md diff --git a/docs/distribution-stage1-framing.md b/docs/distribution-stage1-framing.md new file mode 100644 index 0000000..eb493a1 --- /dev/null +++ b/docs/distribution-stage1-framing.md @@ -0,0 +1,321 @@ +# Framing — Distribution Stage 1: binaries on tag + +**Revision 2.** Status: **approved with amendments** (revision 1 → +2 records them). Scouted against `githubsucks/main` @ `c5f7501` (#209). + +`.github/workflows/` contains exactly one workflow and it is test-only. +**There is no release job, no artifact upload, no tags-to-binaries path.** +Installing pmacs means `git clone` plus knowing the feature-flag matrix. + +**Scope is deliberately one stage: produce binaries when a tag is +pushed, and attach them to a GitHub Release.** Channels, rollback, +update-in-place, signing, and §17's first-launch tool detection are all +**out of scope and named in §5**. This establishes a baseline, not the +arc. + +**Revision 1 → 2**, all from review: + +- **Q#D2 decided with a correction to revision 1's reasoning.** It is + `ubuntu-22.04` with an explicit **glibc ≥ 2.35** floor — and revision 1 + was wrong to imply an older runner reaches RHEL 9. It does not (§1.6). +- **Q#D1 decided**, with acceptance strengthened to assert *both* + binaries report 1.1.0 (§1.3, acceptance 4). +- **The binary-exclusion mechanism is now two-layered**, not one: + explicit `--bin` targets *and* an explicit staged asset list (§1.2). +- **Revision 1 mis-stated `pmacs --gpu`'s failure mode**, and the + corrected version changes *why* co-location is required rather than + whether (§1.2a). +- **Both runners are pinned**, not just Linux (§1.8). +- **The RC is cut after merge from the merge SHA** and the final tag + reuses that same verified SHA (§7). + +--- + +## 0. Coherence impact (COHERENCE §20) + +**Squarely §17 / §20 Priority 8: it completes journey step 1 without +adding an interaction island, a config surface, or a background-work +model.** + +- **Journey step touched: step 1 (install)** — the one step no prior arc + could reach. +- **Concern: §17 Distribution**, graded "missing — zero release + machinery exists." This moves it to **Partial**, not Strong: binaries + on tag is the first of the seven things §17 asks for. +- **Interaction islands added:** none. +- **Config registry adoption:** none — this stage introduces no setting. +- **Background-work attribution:** none — no worker, job, or process. +- **Why now:** §20 P8 says every other priority's value is invisible + until this exists, and its prerequisite — a tree whose tests actually + run — was only satisfied by #209. Before that, a release would have + shipped from a corpus half of which CI had never compiled. + +--- + +## 1. Ground truth (measured at `c5f7501`, 2026-08-01) + +### 1.1 What exists + +Nothing. One workflow, `ci.yml`, test-only; the only `release` strings +in it are `cargo test --release` flags. + +### 1.2 The binary set is NOT what a glob would produce + +`Cargo.toml` declares two `[[bin]]` targets, `pmacs` and `pmacs-audit`. +But **cargo also auto-discovers `src/bin/*.rs`**, so a release build +additionally produces `pmacs_fake_lsp` and `pmacs_fake_mcp` — test +fixtures whose purpose is to be spawned by acceptance suites. Plus +`pmacs-gpu` from its own package. + +**Five binaries can land in `target/release`, and three must never +ship.** + +| binary | ship? | why | +|---|---|---| +| `pmacs` | **yes** | the editor | +| `pmacs-gpu` | **yes** | the GPU frontend | +| `pmacs-audit` | **no** | audits pmacs source against the v1.0 lint rules — a contributor tool with no answer to "what is this for" | +| `pmacs_fake_lsp` | **never** | test fixture | +| `pmacs_fake_mcp` | **never** | test fixture | + +**Exclusion is two-layered, deliberately.** Avoiding an upload glob is +not sufficient on its own: + +1. **Build explicit `--bin` targets** — `--bin pmacs` and the + `pmacs-gpu` package — so the unwanted binaries are not produced by + the release build at all. +2. **Stage an explicit asset list** — copy named files into a staging + directory and archive *that*, so the archive's contents are a + decision rather than a directory's residue. + +Layer 1 without layer 2 still archives whatever a cached +`target/release` happens to hold from an earlier step; layer 2 without +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.2a Why `pmacs` and `pmacs-gpu` must be co-located — corrected + +Revision 1 said separating them makes `--gpu` "silently fail." **That is +wrong, and the real behaviour is better.** `gpu_binary` +(`src/main.rs:304`) prefers a **co-located** `pmacs-gpu` when that path +`is_file()`, otherwise falls back to the bare name `pmacs-gpu` for a +**PATH lookup**; if neither resolves, the error names both — *"sibling +… is absent and PATH lookup for pmacs-gpu failed"*. + +So the requirement is not "otherwise it breaks quietly." It is that +**a release archive must be self-contained**: a user who unpacks it +somewhere not on `PATH` gets a working `--gpu` only if the two binaries +sit together. The fallback is a convenience for installed layouts, not a +substitute for shipping them as a unit. + +### 1.3 The version is already incoherent, and a tag makes it visible + +| crate | now | after | +|---|---|---| +| `pmacs` | 1.0.0 | **1.1.0** | +| `pmacs-gpu` | **0.0.1** | **1.1.0** | +| `pmacs-protocol` | 1.0.0 | **1.0.0** (unchanged) | + +`pmacs-protocol` stays: it is the wire crate, carries publish-shaped +metadata, and versions on its own schedule — the protocol is v21 and +independent of the editor's release number. + +Both shipped binaries report their own `env!("CARGO_PKG_VERSION")`: +`pmacs --version` at `src/main.rs:393`, and `pmacs-gpu --version` at +`pmacs-gpu/src/main.rs:667`, which prints `pmacs-gpu (protocol +v21)` — the protocol number is worth carrying into the release notes. + +Tagging `v1.1.0` against the tree as it stands would publish a release +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.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 +head, **1,025 commits behind `main`**. There is also an `M8` milestone +tag. A `v*`-triggered workflow does not retroactively build for either, +and re-tagging would rewrite a published ref. The first real release is +**`v1.1.0`**, preceded by `v1.1.0-rc.1` (§7). + +### 1.5 CRDT is not optional for a useful release + +`default = ["luajit"]`; `luajit` and `lua54` are **mutually exclusive**; +`crdt` is opt-in on the root package, activated workspace-wide as +`pmacs/crdt`. README's documented build is the one to ship: + +```sh +cargo build --release --workspace --features pmacs/crdt +``` + +**This is not a size/quality trade — a non-CRDT build refuses `--gpu` +outright.** `run_gpu` opens with a guard printing *"pmacs: --gpu +requires pmacs built with --features crdt"*. #209 established the same +thing on the wire side: `InstanceCapabilities::default` advertises +`multi_frontend` / `crdt_replica` / `semantic_render` only under the +feature. A non-CRDT release ships an editor that cannot use the GPU +frontend shipped beside it. + +### 1.6 The glibc floor — decided, and revision 1's reasoning corrected + +**Linux releases build on pinned `ubuntu-22.04`, with a stated support +floor of glibc ≥ 2.35.** + +Revision 1 implied that moving off `ubuntu-latest` buys reach "several +distro generations" including RHEL 9. **It does not, and the arithmetic +matters:** + +| target | glibc | covered by a 22.04 build? | +|---|---|---| +| Ubuntu 22.04 (jammy) | 2.35 | **yes** — this is the floor | +| Debian 12 (bookworm) | 2.36 | **yes** | +| Ubuntu 24.04 | 2.39 | yes | +| **RHEL 9** | **2.34** | **NO — below the floor** | + +RHEL 9 is *older* than the floor, so it needs a lower-glibc container or +cross-build. **That is not a one-word runner change** and is parked +(§5). Stating the floor explicitly is what keeps this honest: a user on +RHEL 9 should read "not supported yet," not discover a loader error. + +References: [runner images](https://github.com/actions/runner-images), +[jammy libc6](https://packages.ubuntu.com/jammy-updates/libc6), +[bookworm libc6](https://packages.debian.org/bookworm/libc6). + +### 1.7 Runtime dependencies are documented but never checked + +README names `/bin/sh`, `stty`, coreutils, git and tar, and tells +packagers to encode them. **Nothing checks any of them at runtime.** +Out of scope here (§5), but the release notes carry the list rather than +assume a downloader reads the README. + +`pmacs-gpu` additionally needs a working Vulkan/Metal adapter; there is +no software-rasterizer fallback in a shipped binary. + +### 1.8 Runner pinning, and what is NOT established + +**Both runners are pinned**, not just Linux: `macos-latest` currently +resolves to macos-15 (ARM64) and **will drift** — the same silent-choice +problem as §1.6, in the other direction, where a future move could +change the minimum supported macOS without a commit to point at. + +- **No release has ever been produced**, so nothing here is verified + end-to-end. Unlike #209 there is no existing behaviour to measure + against; the RC is the first evidence. +- **No Intel macOS** (§5). arm64 only. +- **No signing or notarization.** A downloaded macOS binary is + Gatekeeper-quarantined and needs an explicit override. Expected for an + unsigned baseline; it belongs in the release notes rather than in a + claim that macOS "just works". + +--- + +## 2. Decisions (all questions resolved at approval) + +- **Q#D1 — versions.** `pmacs` and `pmacs-gpu` → 1.1.0; + `pmacs-protocol` stays 1.0.0. Acceptance asserts both shipped binaries + *report* 1.1.0 and the tag is `v1.1.0`. +- **Q#D2 — Linux runner.** Pinned `ubuntu-22.04`; support floor + glibc ≥ 2.35, covering Ubuntu 22.04 and Debian 12 but **not RHEL 9**. +- **Q#D3 — `pmacs-audit`.** Does not ship. +- **Q#D4 — Intel macOS.** Not in the baseline. macOS runner pinned. +- **Q#D5 — checksums.** Ship them. +- **Q#D6 — tests on release.** Do not re-run the suite; assert the + tagged commit is an ancestor of `main`, which catches the real mistake + (tagging a branch) at negligible cost. + +--- + +## 3. Bets + +- **Bet 1 — the release build is the CI build with different flags** and + will succeed first time. +- **Bet 2 — the artifact, not the build, is where this goes wrong.** The + plausible failures are packaging-shaped: a stray binary, a split + archive, a version mismatch, a glibc floor nobody notices. +- **Bet 3 — one RC is enough evidence.** A release either produces two + runnable, correctly-versioned binaries at a URL or it does not. + +--- + +## 4. Acceptance + +1. Pushing a tag matching `v*` produces a **GitHub Release** with + attached artifacts; no other trigger produces one. `v1.1.0-rc.1` is + marked **prerelease**. +2. Each archive's **complete member list** is asserted, along with + **executable bits**, and the **absence of `pmacs-audit`, + `pmacs_fake_lsp` and `pmacs_fake_mcp`**. Verified by listing the + downloaded archive, not by trusting the build command. +3. `pmacs` and `pmacs-gpu` are in the **same directory** within the + archive, so an unpacked release is self-contained (§1.2a). +4. **Version coherence, asserted from the binaries:** the workflow + refuses to publish when the tag disagrees with the root crate + version, and the downloaded `pmacs --version` and `pmacs-gpu + --version` both report **1.1.0**. Verified by a deliberate mismatch, + not by inspection. +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. +8. `SHA256SUMS` covers every published artifact and verifies against the + downloads. +9. Release notes carry the runtime dependencies (§1.7), the glibc floor, + the arm64-only macOS scope, and the unsigned/Gatekeeper caveat. +10. `README.md`'s install section offers the download path **before** + from-source. + +--- + +## 5. Parked (explicitly out of scope) + +Named so they read as decisions. All are §17 requirements this stage +does not meet: + +- **RHEL 9 and older glibc** — needs a container or cross-build (§1.6). +- **Intel macOS.** +- **Release channels**, stable/nightly. +- **Update-in-place and rollback.** +- **Signing and notarization.** +- **Reproducible builds.** +- **Package managers** — Homebrew, AUR, nixpkgs, distro packages. +- **Windows.** Unsupported anywhere in the tree today. +- **First-launch experience** — §17's config-directory creation and + optional-tool detection. That is §18 onboarding work. +- **Protocol/package-API compatibility reporting.** + +--- + +## 6. Gates + +The standing `CLAUDE.md` suite applies unchanged; this stage adds no +Rust logic and no test, beyond the version bumps which the whole suite +covers. **Its real verification is the RC artifact** — acceptance 2, 3, +4, 6, 7 and 8 are all performed against a *downloaded* archive, because +that is the only thing that tests a release. + +--- + +## 7. Branch plan + +One branch, `distribution-stage1`: + +1. **Bump `pmacs` and `pmacs-gpu` to 1.1.0** (Q#D1). +2. **Add `.github/workflows/release.yml`** — `on: push: tags: ['v*']`, + pinned `ubuntu-22.04` + pinned macOS runner, explicit `--bin` + targets, explicit staged asset list, the tag/version assertion, the + ancestor check, and `SHA256SUMS`. +3. **Update `README.md`** so download precedes build-from-source, and + state the glibc floor and macOS scope. +4. **Merge.** +5. **Tag `v1.1.0-rc.1` from the merge SHA**, marked prerelease. Verify + acceptance 2–8 against the published artifacts. +6. **Tag `v1.1.0` from that same verified SHA.** + +**Steps 5 and 6 are the point of the ordering.** A tag on a branch would +publish a release from unmerged code, so the RC necessarily follows the +merge — and it is cut *from the merge SHA*, so the final tag can reuse +the exact commit the RC verified. **If the RC finds a defect, it is +fixed in a new PR and another RC is cut; the final tag is never the +first live execution of this workflow.** From 84b1620e7e9e3d01d33ce803aac557b4d2111ae8 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 1 Aug 2026 14:40:47 -0400 Subject: [PATCH 2/3] =?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, + ) } } From d0570cdf726f687c23925f4b0ee3bfd3f1148fcd Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Sat, 1 Aug 2026 14:46:27 -0400 Subject: [PATCH 3/3] docs(release): a tag before the merge does nothing, silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operational note for §7's step 5, recorded before it can bite rather than after. For `on: push: tags`, GitHub resolves the workflow file AS IT EXISTS AT THE TAGGED COMMIT, and it registers a repository's workflows from the DEFAULT BRANCH. Verified while #211 was in review: `gh workflow list` reports only CI, because release.yml lives on the branch and not yet on main. So tagging any commit that predates the merge produces no run, no error, and no release. That failure mode is worse than a red one: a silent no-op is indistinguishable from "the run has not started yet", which is exactly what someone watching for an RC would assume. Cut the RC from the merge SHA and confirm a run actually appeared before drawing any conclusion from its absence. Co-Authored-By: Claude Opus 5 (1M context) --- docs/distribution-stage1-framing.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/distribution-stage1-framing.md b/docs/distribution-stage1-framing.md index 833b43c..9600e56 100644 --- a/docs/distribution-stage1-framing.md +++ b/docs/distribution-stage1-framing.md @@ -417,6 +417,17 @@ One branch, `distribution-stage1`: acceptance 2–8 against the published artifacts. 6. **Tag `v1.1.0` from that same verified SHA.** +**A tag before the merge does nothing, silently.** For `on: push: +tags`, GitHub resolves the workflow file **as it exists at the tagged +commit** — and it lists a repository's workflows from the *default +branch*, so `release.yml` is not even registered until this PR merges +(verified: `gh workflow list` shows only CI while the file lives on the +branch). Tagging any commit that predates the merge therefore produces +no run, no error, and no release. **A silent no-op is the worst possible +outcome for a release step, because it is indistinguishable from "not +started yet."** Cut the RC from the merge SHA and confirm a run actually +appeared before concluding anything about it. + **Steps 5 and 6 are the point of the ordering.** A tag on a branch would publish a release from unmerged code, so the RC necessarily follows the merge — and it is cut *from the merge SHA*, so the final tag can reuse